自学内容网 自学内容网

多线程(二)- Java内置锁的核心原理

前言

Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须等待或者阻塞,直到线程A释放这个锁,如果线程A不释放这个锁,那么线程B将永远等待下去。

Java中每个对象都可以用作锁,这些锁称为内置锁。线程进入同步代码块或方法时会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或方法。

一、线程安全问题

当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。

1.自增运算不是线程安全的

下面写一个例子

package test.juc.safe;

import java.util.concurrent.CountDownLatch;

/**
 * 用10个线程自增,最后结果汇总
 */
public class SelfPlusTest {
    private static final int THREAD_SIZE = 10;
    private static final int ADD_TIMES = 1000;

    public static void main(String[] args) throws InterruptedException {
        SelfPlus selfPlus = new SelfPlus();
        //用于让线程全部跑完(countDownLatch归0),才获取计算结果
        CountDownLatch countDownLatch = new CountDownLatch(THREAD_SIZE);
        for (int i = 0; i < THREAD_SIZE; i++) {
            new Thread(() -> {
                for (int j = 0; j < ADD_TIMES; j++) {
                    selfPlus.selfAdd();
                }
                countDownLatch.countDown();
            }).start();
        }
        //等待所有线程跑完
        countDownLatch.await();
        long amount = selfPlus.getAmount();
        System.out.println("预期计算结果:" + THREAD_SIZE * ADD_TIMES);
        System.out.println("实际计算结果:" + amount);
    }
}
package test.juc.safe;

public class SelfPlus {
    private Integer amount = 0;

    public void selfAdd() {
        amount++;
    }

    public Integer getAmount() {
        return amount;
    }

}

按照常规理解,预期结果应该是10万,但是结果不是。

原因分析

为什么自增运算符不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:​“内存取值”​“寄存器增加1”和“存值到内存”​。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。

比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入amount的内存,amount的结果是101,而不是103。

“内存取值”​“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。

2.synchronized关键字

每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)​。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。

在前面1的例子中,对selfAdd加上synchronized关键字,就相当于给这个方法的存取操作做了排他处理。方法只有在一个线程运行完后,另一个线程才能运行这个方法,其他线程只能等待,就可以解决线程安全问题。

    public synchronized void selfAdd() {
        amount++;
    }


 

3.生产者消费者问题

生产者-消费者问题(Producer-Consumer Problem)也称有限缓冲问题(Bounded-Buffer Problem)​,是一个多线程同步问题的经典案例。

生产者-消费者问题描述了两类访问共享缓冲区的线程(所谓的“生产者”和“消费者”​)在实际运行时会发生的问题。生产者线程的主要功能是生成一定量的数据放到缓冲区中,然后重复此过程。消费者线程的主要功能是从缓冲区提取(或消耗)数据。

生产者-消费者问题的关键是:

(1)保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据。

(2)保证在生产者加入过程、消费者消耗过程中,不会产生错误的数据和行为。

生产者-消费者问题不仅仅是一个多线程同步问题的经典案例,而且业内已经将解决该问题的方案抽象成了一种设计模式——“生产者-消费者”模式。​“生产者-消费者”模式是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。

3.1 生产者-消费者模式

在生产者-消费者模式中,通常有两类线程,即生产者线程(若干个)和消费者线程(若干个)​。生产者线程向数据缓冲区(DataBuffer)加入数据,消费者线程则从数据缓冲区消耗数据。

在生产者-消费者模式中,至少有以下关键点:

(1)生产者与生产者之间、消费者与消费者之间,对数据缓冲区的操作是并发进行的。

(2)数据缓冲区是有容量上限的。数据缓冲区满后,生产者不能再加入数据;数据缓冲区空时,消费者不能再取出数据。

(3)数据缓冲区是线程安全的。在并发操作数据缓冲区的过程中,不能出现数据不一致的情况;或者在多个线程并发更改共享数据后,不会造成出现脏数据的情况。

(4)生产者或者消费者线程在空闲时需要尽可能阻塞而不是执行无效的空操作,尽量节约CPU资源。

为了解决生产者消费者存在的线程安全问题,可以用synchronized关键字,这样一来,所有的生产、消费动作在执行过程中都需要抢占同一个同步锁,最终的结果是所有的生产、消费动作都被串行化了。生产、消费动作肯定不能串行执行,而是需要并行执行,而且并行化程度越高越好。如何既保障没有线程安全问题,又能提高生产、消费动作的并行化程度呢?就是使用下面要介绍的Java内置锁。

3.2 Java对象结构和内置锁

在介绍Java内置锁之前,需要介绍一下Java对象结构

3.2.1 Java对象结构 

Java对象结构包括三部分:对象头、对象体和对齐字节

1)对象头
对象头包括三个字段,第一个字段叫作Mark Word(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态等信息。
第二个字段叫作Class Pointer(类对象指针),用于存放方法区Class对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第三个字段叫作Array Length(数组长度)。如果对象是一个Java数组,那么此字段必须有,用于记录数组长度的数据;如果对象不是一个Java数组,那么此字段不存在,所以这是一个可选字段。

(2)对象体
对象体包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。

(3)对齐字节
对齐字节也叫作填充对齐,其作用是用来保证Java对象所占内存字节数为8的倍数HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍。对象头本身是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。

3.2.2 Mark Word的结构信息

Mark Word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark Word为32位,64位JVM为64位。Mark Word的位长度不会受到Oop对象指针压缩选项的影响。

不同状态下的Mark Word字段结构

Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁。其实在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量级锁),并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)

32位Mark Word的结构信息

64位Mark Work的结构信息

3.2.3 64位Mark Word的构成

(1)lock:锁状态标记位,占两个二进制位,由于希望用尽可能少的二进制位表示尽可能多的信息,因此设置了lock标记。该标记的值不同,整个Mark Word表示的含义就不同。
(2)biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。

lock和biased_lock两个标记位组合在一起共同表示Object实例处于什么样的锁状态,二者组合的含义具体如下

(3)age:4位的Java对象分代年龄。在GC中,对象在Survivor区复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

(4)identity_hashcode:31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法或者System.identityHashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器)中。

(5)thread:54位的线程ID值为持有偏向锁的线程ID。

(6)epoch:偏向时间戳。

(7)ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针。

(8)ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。

3.3 无锁、偏向锁、轻量级锁、重量级锁

3.3.1 无锁状态

Java对象刚创建时,还有任何线程来竞争,对象处于无锁状态。这时偏向锁的标识位是0,锁状态是01

3.3.2 偏向锁状态

偏向锁是指对象一直被同一个线程访问,那么该线程会自动获取锁,以降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下,效率非常高。 偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID。

3.3.3 轻量级锁状态

当有两个线程开始竞争这个锁对象时,情况发生变化了,不再是偏向锁(独占锁)了,锁会升级为轻量级锁,两个锁公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。

当锁处于偏向锁又被另一个线程企图抢占时,偏向锁会升级为轻量级锁。企图抢占的线程会通过自旋的方式尝试获取锁,不会阻塞抢锁线程,以便提高性能。

自旋的原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,他们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核切换的消耗。

但是线程自旋是需要消耗CPU的,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。

如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。

3.3.4 重量级锁状态

重量级锁会让其他申请锁的线程进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。

3.3.5  偏向锁、轻量级锁与重量级锁的对比

3.4  线程间通信

3.4.1 定义

多个线程间按照指定的规则公共完成一件任务,这些线程之前就需要互相协调,这个过程被称为线程的通信。线程间的通信有很多种:等待-通知、共享内存、管道流。

3.4.2 wait、notify方法的原理

3.4.2.1 对象的wait()方法

对象wait()方法的主要作用是让当前线程阻塞并等待被唤醒。wait()方法与对象监视器紧密相关,使用wait()方法时也一定需要放在同步代码块中

Object类中的wait方法有三个版本

(1) void wait()
当调用了同步对象的wait实例方法后,当前线程将进入等待,当前线程进入looko的监视器的WaitSet,等待被娶其他线程唤醒

(2) void wait(long timeout)

阻塞等待的超时版本,等待指定的时间

(3) wait(long timeout , int nanos)

阻塞等待的超时版本,可以设置等待时长,更精确的控制等待时间,实现更高精度的等待

3.4.3 wait方法的核心原理

1)当线程调用了lock实例的wait方法后,JVM会当前线程加入locko监视器的ViewSet,等待被其他线程唤起

2)当线程会释放lokco对象监视器的Owner权利,让其他线程可以抢夺locko对象的监视

3)让当前线程等待,使其状态变成WAITTING

3.4.4 对象的notify()方法

对象的notify()方法的主要作用是唤醒在等待的线程。notify()方法与对象监视器紧密相关,使用notify()方法时也需要放在同步代码块中。

(1)void notify()

调用后,唤醒locko监视器等待集中的第一个等待线程;

被唤醒的线程进入EntryList,其状态从WAITTING等待状态变成BLOCKED

(2)void notifyAll()

locko.notigyAll()被调用后,唤醒locko监视器等待集中的全部等待线程;

所有被唤醒的线程进入EntryList,线程状态从WAITING等待状态变成BLOCKED

3.4.5 notify()方法的核心原理

1)当线程调用了某个对象的notify()方法后,JVM会唤醒对象实例监视器WaitSet中的第一个等待线程

2)当线程调用了某个对象的notigyAll()方法后,JVM会唤醒对象实例监视器WaitSet中的所有等待线程

3)等待线程被唤醒后,会从监视器的WaitSet移动到EntryList,线程具备了排队抢夺监视器Owner的权利,其状态从WAITING变成BLOCKED

4)EntryList中的线程抢夺到资源器Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格


原文地址:https://blog.csdn.net/qq_38984332/article/details/142727088

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!