Fork me on GitHub

synchronized与volatile原理解析

线程安全

在学synchronized和volatile之前,我们先来了解一个概念——什么是线程安全?
线程安全简单来说就在多线程的情况下也不会有问题,看似一句废话,要怎么理解呢
比如ArrayList不是线程安全的就是一个线程不安全的类,
如果两个线程对可以同一个ArrayList进行add操作会出现什么结果?请看下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyTest {
            static List<Integer> list = new ArrayList<>();

            static class BB implements Runnable {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        list.add(j);
                    } } }

public static void main(String[] args) throws InterruptedException{

                BB b = new BB();
                Thread t1 = new Thread(b);
                Thread t2 = new Thread(b);
                t1.start();
                t2.start();
                t1.join();
                t2.join();

                System.out.println(list.size());
            }
        }

问题出在add方法

1
2
3
public boolean add(E e) {   ensureCapacityInternal(size + 1);   
elementData[size++] = e; 
return true;}

上面的程序,可能有三种情况发生:

  • 数组下标越界。首先要检查容量,必要时进行扩容。每当在数组边界处,如果A线程和B线程同时进入并检查容量,也就是它们都执行完ensureCapacityInternal方法,因为还有一个空间,所以不进行扩容,此时如果A暂停下来,B成功自增;然后接着A从 elementData[size++]=e开始执行,由于A之前已经检查过没有扩容,而B成功自增使得现在没有空余空间了,此时A就会发生数组下标越界。
  • 小于20000。size++可以看成是 size=size+1,这一行代码包括三个步骤,先读取size,然后将size加1,最后将这个新值写回到size。此时若A和B线程同时读取到size假设为10,B先自增成功size变11,然后回来A因为它读到的size也是10,所以自增后写入size被更新成11,也就是说两次自增,实际上size只增大了1。因此最后的size会小于200。
  • 等于20000 很幸运,没有发生上面情况
    顺便说一句,线程越多,或者加的数越大越可能出现不安全的问题

synchronized:“这条桥上一次只能过一个人”

关键词

互斥、JVM内置锁、对象锁、可重入(避免死锁)

有什么用

  • 原子性:确保线程互斥的访问同步代码;
  • 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
  • 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”

怎么用

当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this)

当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁

当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例

为什么

想了解synchronized的原理,首先要了解两个东西:对象头中的Mark Word和Monitor,说白了,前者是资源的锁,后者是拥有者线程的锁记录。加锁要修改拥有者和资源的锁记录才行。

加锁的时候首先要添加拥有者线程的锁记录,虚拟机会在当前线程的栈帧中建立一个名为锁记录( Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝

然后要修改资源的锁记录,虚拟机将使用CAS操作尝试将对象的 Mark Word更新为指向 Lock record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁定状态。

Monitor

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,它依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

对象头中的Mark Word多线程概念

对象头:Mark Word(标记字段)、Klass Pointer(类型指针)、数组长度数据(可选)

实例数据:存放类的属性数据信息,包括父类的属性信息;

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须的,仅仅是为了字节对齐;

Mark Word中四种锁状态标识

锁状态 存储内容 存储内容
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10

注意:无锁和偏向锁使用的是一个标志位,但偏向锁还有是否是偏向锁标志位、线程ID、Epoch等

接下来我将顺着标识位来讲synchronized的四种锁状态。

四种锁状态

JDK 6之前synchronized效率低,是因为依赖于操作系统Mutex Lock,即“重量级锁”,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

JDK 6之后的synchronized锁级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

消耗 缺点 适用场景
偏向锁 加锁和解锁时对比Mark Word,只需一次CAS原子指令 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 将Mark word拷贝到栈帧的锁记录中,再将Mark Word替换为锁记录的指针。多个CAS操作和自旋。 自旋会消耗CPU,但相应时间快 追求响应速度,同步块执行速度非常快
重量级锁 依赖于操作系统Mutex Lock,等待线程被阻塞挂起 线程阻塞响应时间缓慢,但不会消耗CPU 追求吞吐量,同步块执行速度较长

偏向锁

HotSpot发现:在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

虚拟机会在当前线程的栈帧中建立一个名为锁记录( Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
然后虚拟机将使用CAS操作尝试将对象的 Mark Word更新为指向 Lock record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁定状态。即上面说的加锁要修改拥有者和资源的锁记录才行。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

volatile

有什么用

volatile是一个关键字,用于修饰变量。被其修饰的变量具有可见性和有序性。

  • 可见性,当一条线程修改了这个变量的值,新值能被其他线程立刻观察到。其实这里要结合Java内存结构来说:在缓存在本CPU对变量的修改直接写入主内存中,同时这个写操作使得其他CPU中对应变量的缓存行无效,这样其他线程在读取这个变量时候必须从主内存中读取,所以读取到的是最新的,这就是上面说得能被立即“看到”。
  • 有序性,即volatile可以禁止指令重排。volatile在其汇编代码中有一个lock操作,这个操作相当于一个内存屏障,指令重排不能越过内存屏障。具体来说在执行到volatile变量时,内存屏障之前的语句一定被执行过了且结果对后面是已知的,而内存屏障后面的语句一定还没执行到;在volatile变量之前的语句不能被重排后其之后,相反其后的语句也不能被重排到之前。

原理

加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。

lock前缀会做两件事情:

  • 将当前处理器缓存行的数据会写回到系统内存。这可以保证写操作强制被更新到处理器,处理器的值是最新的。
  • 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。这可以保证每个线程总是去处理器读,而不是使用自己可能过期的缓存数据。以实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

也就符合了happens-before原则中——同一时间对volatile变量的写操作总先于读操作,实现了程序的有序性。(如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。)

经典场景

chm的get方法是不加锁的,因为get方法里的共享变量都定义成volatile类型,保证能被多线程的读,但只能被单线程的写。即使一个线程在读一个线程同时在写,根据happen before原则,对volatile字段的写入先于读操作,所以get总能拿到最新的值。这是用volatile替换锁的经典场景。

其他

为什么wait,notify在Object类里,而把sleep放在Thread类里面?

sleep和wait的区别在于:sleep方法没有释放锁,而wait方法释放了锁。

一个线程可以拥有多个对象锁,wait,notify,notifyAll跟对象锁之间是有一个绑定关系的,假如用Thread.wait(),Thread.notify(),Thread.notifyAll()来调用,虚拟机根本就不知道需要操作的对象锁是哪一个。