前导说明:
本文基于《深入理解Java虚拟机》第二版和个人理解完成,
以大白话的形式期望能用大家都看懂的描述讲清楚虚拟机内幕,
后续会增加基于《深入理解Java虚拟机》第三版内容,并进行二个版本的对比。
阻塞同步(悲观锁)
就是保障在多线程并发中在一个时刻只有一个线程能访问共享变量。通过monitorenter和monitorexit字节码指令来对代码块内的代码进行同步访问,且通过锁对象的reference属性变量来记录锁的计数器。就是执行monitorenter后计数器+1,执行monitorexit后计数器-1,以此来保障两个指令是一一对应的,这样就不会死锁了。synchronized是可以重入的,就是得到锁后,仍然可以继续得到当前锁对象的锁,此时monitorenter的计数器reference就会+1,所以释放锁时也会-1,即如果获取了2次锁,那么释放锁时也需要释放2次才行。因为java的线程模型是与轻量级进程对应的,即线程的切换和调度都是由内核完成的,这就需要内核态的切换,特别是当一个线程得到锁而其他线程需要阻塞时,阻塞是由系统内核执行的,所以阻塞就需要内核态的切换,这样是有一定资源消耗的,所以synchronized也称为重量级锁。
阻塞同步也即互斥同步,也可以叫悲观锁,缺点主要就是进行线程阻塞和唤醒所带来的性能问题,因为需要用户态、内核态不断切换。
1.synchronized关键字实现同步锁
我们看一下synchronized的特性:
- 1.是可重入锁。
- 2.非公平锁,就是处于阻塞等待的线程,得到锁的顺序是随机的,跟谁先排队无关。
- 3.通过关键字语义实现的锁功能,此描述用来与java.lang.concurrent.Lock对比用的,后者是通过java代码语法实现的锁功能。
- 4.优先使用synchronized,因为他是JVM自带的原语式的支持,且性能跟java.util.concurrent.Lock持平。
- 5.使用方法:
- 1.同步块:synchronized(参数),其中参数指定具体的共享对象,即锁对象
- 2.同步方法:该情况的共享变量为当前对象的this,即当前类的实例对象将作为锁对象
- 3.静态同步方法:此时的锁就是当前的类对象了,因为静态属于类
- 6.异常释放锁:当同步块或同步方法中出现异常,会由JVM自动释放对象的锁,不会导致死锁。
2.java.util.concurrent.Lock
此处我们看两种Lock的实现:ReenTrantLock同步锁和ReentrantReadWriteLock读写锁。
我们看一下ReenTrantLock的特性:
- 1.是可重入锁。
- 2.公平锁,默认是非公平锁,可以通过传递true到构造器创建公平锁,以便达到让排队等待锁的线程,按照谁先排队谁先得到锁的顺序执行。
- 3.可中断等待,可以调用tryLock()方法传递等待时间,时间到达后从阻塞退出不在等待锁,可以去做其他事情。调用lockInterrupted()方法,可以让等待锁的这个线程,通过在其他地方手动调用中断,让这个线程中断,即停止等待锁,而synchronized则不能。
- 4.可以绑定多个Condition条件,比synchronized中使用wait、notify、notifyAll更加灵活,见下面生产者、消费者的例子。
- 5.API式实现锁:需要手动调用lock()或tryLock()获得锁,并通过在finally中使用unlock()方法释放锁。
/** * 生产者、消费者的例子(synchronized实现方式) */ public class Test { private int queueSize = 10;//队列大小10 // 使用一个普通队列集合存放数据[PriorityQueue是非阻塞队列,是无限大小的队列] private PriorityQueue<Integer> queue = new PriorityQueue<Integer>(queueSize); public static void main(String[] args) { Test test = new Test(); //创建生产者、消费者线程对象 Producer producer = test.new Producer(); Consumer consumer = test.new Consumer(); //运行线程 producer.start(); consumer.start(); } } /** * 消费者 */ class Consumer extends Thread{ public void run() { while(true){ synchronized (queue) {//避免共享变量问题,加阻塞同步锁 while(queue.size() == 0){ //当队列中没有数据时,此时无法消费,需要阻塞,即等待挂起 try { System.out.println("队列空,等待数据"); queue.wait(); } catch (InterruptedException e) { queue.notify(); //如果异常了就执行唤醒生产者的操作 } } queue.poll();//如果不为空则从队列头部取一个数据并删除,即消费 queue.notify();//避免生产者等待,所以执行一次唤醒 } } } } /** * 生产者 */ class Producer extends Thread{ public void run() { while(true){ synchronized (queue) { while(queue.size() == queueSize){ try { System.out.println("队列满,等待有空余空间"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notify(); } } queue.offer(1); //每次插入一个元素 queue.notify(); System.out.println("向队列取中插入一个元素,队列剩余空间:"+(queueSize-queue.size())); } } } }/** * 生产者、消费者的例子(synchronized实现方式) */ public class Test { private int queueSize = 10; private PriorityQueue<Integer> queue = new PriorityQueue<Integer>(queueSize); private Lock lock = new ReentrantLock(); private Condition notFull = lock.newCondition(); private Condition notEmpty = lock.newCondition(); public static void main(String[] args) { Test test = new Test(); Producer producer = test.new Producer(); Consumer consumer = test.new Consumer(); producer.start(); consumer.start(); } } /** * 消费者 */ class Consumer extends Thread{ public void run() { while(true){ lock.lock(); try { while(queue.size() == 0){ try { System.out.println("队列空,等待数据"); notEmpty.await(); } catch (InterruptedException e) { e.printStackTrace(); } } queue.poll(); //每次移走队首元素 notFull.signal(); System.out.println("从队列取走一个元素,队列剩余"+queue.size()+"个元素"); } finally{ lock.unlock(); } } } } /** * 生产者 */ class Producer extends Thread{ public void run() { while(true){ lock.lock(); try { while(queue.size() == queueSize){ try { System.out.println("队列满,等待有空余空间"); notFull.await(); } catch (InterruptedException e) { e.printStackTrace(); } } queue.offer(1); //每次插入一个元素 notEmpty.signal(); System.out.println("向队列取中插入一个元素,队列剩余空间:"+(queueSize-queue.size())); } finally{ lock.unlock(); } } } }
我们再看一下ReentrantReadWriteLock的特性:其实他是实现ReadWriteLock接口,并实现了读写锁能力,意思就是读可以进行并发,不存在线程安全,如果写的话是需要考虑线程安全问题。
- 1.如果当前占用的是读锁,那么其他线程获取读锁不阻塞,但是获取写锁时会阻塞。
- 2.如果当前占用的是写锁,那么其他线程获取读锁或写锁都将阻塞。
// 例子 public class Test { private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); public static void main(String[] args) { final Test test = new Test(); new Thread(){ public void run() { test.get(Thread.currentThread()); }; }.start(); new Thread(){ public void run() { test.get(Thread.currentThread()); }; }.start(); } public void get(Thread thread) { rwl.readLock().lock(); try { while(true) { System.out.println(thread.getName()+"正在进行读操作"); } } finally { rwl.readLock().unlock(); } } }此处只用了读锁,可以看到结果是交替输出,说明两个线程可以同时获得读锁,即不会阻塞;而当其他线程通过rwl.writeLock().lock()时将会阻塞。
非阻塞同步(乐观锁)
就是在访问共享变量时,不采取加锁的方式,而是基于冲突检查的乐观并发策略;通俗的说就是先进行操作,如果没有其他线程争抢共享数据,那操作就成功了;如果共享数据有争抢,产生了冲突,那就采取其他的补救措施(最常见的补救措施就是不断的重试,直到成功为止),这种实现不需要让并发争抢的线程挂起阻塞,所以叫做非阻塞同步。
我们通过对内存模型的了解可以知道,如果上边所说的操作+冲突检查是原子性操作的话,就不存在线程安全问题,因为如果不是原子性那线程就不安全了(两个操作之间就可能被加塞等),这种情况本身线程都不安全,我们再用一个线程不安全的操作来实现线程安全就没意义了。所以如果要通过非阻塞同步方案实现线程安全,那就需要非阻塞同步本身的操作是原子性的,当然可以通过synchronized来保障原子性,但是那就是阻塞同步了,所以这个方案需要硬件指令集的支持,目前一般的硬件都支持多种指令集。支持此方案的指令集如下:
- 1.测试并设置(Test-and-Set)
- 2.获取并增加(Fetch-and-Increment)
- 3.交换(Swap)
4.比较并交换(Compare-and-Swap,CAS)5.加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)
Java的实现主要采用的是CAS指令,个别特殊环境下可能会使用LL/SC指令。
CAS指令的操作流程:共三个操作数
- 1.变量的内存地址,用N表示。
- 2.旧的预期值,用A表示。
- 3.新值,用B表示。
当CAS指令执行时,当且仅当N符合旧的预期值A时,处理器用新值B更新N中的值,否则不执行更新,但无论是否更新了N中的值,都会返回N中的旧值。
这是一个原子操作。
JDK 1.5后,CAS方式的非阻塞同步实现就可以用了,该操作由sun.misc.Unsafe类里边的cas方法提供的底层实现,Unsafe类中的很多方法都是原子操作,但是由于使用起来容易产生风险,所以除了BootstrapLoader类加载器加载的类能使用它之外我们自己定义的类是无法使用的,除非通过反射机制,所以也不太建议平时随意使用,如果要实现CAS操作,可以通过借助java.util.concurrent.atomic包中的类来实现。
总结:从线程安全我们看看CAS都解决了那些问题?原子性、可见性、顺序?
解答:CAS因为是原子操作,所以肯定符合原子性,又因为他的操作中只有这么一个原子操作,所以顺序性符合,因为他压根就不会重排序,因为只有一个指令;因为原子性操作中就是有判断的先检查再赋值,所以也就符合了可见性,操作完成即得到结果。
疑惑: 既然CAS这么完美为什么还要volatile(可见性、禁止重排序(顺序性))和synchronized(可见性、顺序性、原子性)呢?
解答: 从CAS的判断实现上看,他就是无限循环去冲突检查然后成功再去执行,首先这个循环肯定是有损性能的。再有就是他有个ABA问题,就是如果首次读取N的时候值确实是A,并且在准备赋值时检查仍然是A,但是有可能在两次检查区间A被改成了B,紧接着又被改回了A,这对CAS是不影响的,其实他对线程安全也是没有影响的,只不过是有这么一种可能的漏洞,JDK也在尽力修复此问题,但是目前还没有修复,所以综合起来看,还是使用synchronized比较万无一失,而且结合volatile会更加细粒度的控制线程的安全,比如双重检查的案例。
无同步方案
首先线程安全问题都是使用了共享变量才出现的,如果没有共享变量那么当然就不需要进行阻塞或非阻塞同步了。也可以通过传递参数让各个线程获得对应的结果,比如使用ThreadLocal对象来为各个线程提供服务,且各个线程中都独享自己的ThreadLocal。
总结:
- ①阻塞同步:
- 1.synchronized可以保障原子性、顺序性、可见性。
- 2.Lock锁- ②非阻塞同步:
- 1.volatile可以保障可见性、顺序性。
- 2.cas可以保障原子性,间接保障顺序性、可见性。- ③无同步方案:当然就是没有共享变量的情况了,当然也不需要进行阻塞/非阻塞同步操作。
也可以借助线程独享的属性,ThreadLocal对象来完成数据传递。
锁优化
上边我们讲了线程安全的实现方法,是有关锁的使用方式,那么锁在JVM中运行时,它会有一个优化的过程,而这个过程是由JVM自动完成的,下边我们来看一下都有哪些对锁的优化。
1.自旋锁/自适应自旋锁
目的: 是为了减少阻塞同步中线程阻塞挂起和切换恢复线程带来的性能压力,因为需要用户态、内核态切换。
自旋锁概念: 就是在线程获取锁失败需要进去阻塞挂起等待时,不进直接进入挂起等待,而是进行一定时间的空循环,而此时是不会让出CPU处理器的执行时间的,以此来间接等待锁的释放,以此来降低内核态切换带来的性能问题,但是也不能无限时间的循环等待,避免资源更加浪费,所以默认是执行10次空循环,10次仍然等不到锁后则根据阻塞同步的规则,直接进入阻塞等待。而这种空循环等待的方式就叫做自旋锁。默认此功能是开启的,不需要我们代码做任何更改,JVM会自动完成这个工作。可以通过参数-XX:PreBlockSpin来更改循环次数。
自适应自旋锁: 默认也是开启的,这个比自旋锁更加智能,就是空循环的次数是由JVM通过以往的对某个锁对象获取锁的成功时间间隔来自适应的设置空循环的次数,如果以往某个对象的锁很快就能得到,那么自旋就一直循环下去直到拿到锁;如果以往某个对象的锁很难拿到(可能得到锁的线程的处理逻辑耗时较长),那么空循环的次数就会减少甚至是不使用自旋,而直接让线程进入阻塞等待,此功能也是由JVM底层自动完成,无需代码特殊操作,所以知道有这么个优化过程即可。2.锁消除
就是JVM通过JIT即时编译器编译时,如果发现我们写的有些代码不可能会访问共享变量但却也加了同步块,这时JVM将会自动的把这些同步块给取消掉,当然取消后就跟没有同步时那样直接执行了。
疑问: 既然知道不会访问共享变量,开发者为什么还要加同步呢?
答案: 有些情况并不是开发者自己加的,比如public String appendMethod(String a1,String a2,String a3){ return a1+a2+a3; }我们知道String是不可变的,当对String进行连接操作时,他都会返回一个新对象,所以当JIT即时编译器进行编译时它会自动对这个代码进行优化,它会转化成使用StringBuffer的append()方法来操作,以便节省资源,但是StringBuffer是线程安全的,所以这就带来了不必要的同步锁的问题。
public String appendMethod(String a1,String a2,String a3){ StringBuffer sb = new StringBuffer(); sb.append(a1); sb.append(a2); sb.append(a3); return sb.toString(); }不过,JDK 1.6后,就改成使用StringBuilder了,所以就不是线程同步的了,也就没有这个锁消除的优化了,不过这里只是为了说明代码中是存在我们不知道但被加上了同步的地方,所以这个锁消除是有用的,知道即可。
3.锁粗化
这个和锁消除相反是JVM会在适当的时候加锁,这么说不太对,他是会把锁的范围自动扩大。因为我们使用锁的原则是尽量的细粒度使用锁,但是有些时候力度太细就会频繁的加锁、解锁,这样反而会影响性能,比如在一个循环内部加锁,那就比较不合适,JVM发现后就会自动把锁设置到循环的外侧执行。4.轻量级锁
目的: 在没有多线程争抢共享变量的前提下,减少使用重量级锁(synchronized)给操作系统带来的性能消耗。
原理: 这个我们在前边讲解堆中对象的存储结构时已经讲过一部分内容,此处我们再讲一下,如下图:对象存储于堆中的结构。
由图可知,每个部分的存储空间大小,图中是64位虚拟机的存储结构,如果是32位虚拟机,那么Mark Word等标黄的部分都将会是32bit,即4个字节。
对象实例存储结构分为:对象头和对象体。
- 1.对象头
- 1.Mark Word:这部分用来存储哈希码(hashcode)、对象锁或GC标记,具体存什么要根据当前对象的锁的状态而定。
- 1.对象头的不同状态以及不同状态下存储的内容
- 2.由图可知,当对象没有加锁时,存储的是哈希码、对象分代年龄。
- 3.当对象状态是轻量级锁定时,存储的是指向锁记录的指针,此时的指针则是指向获取了当前轻量级锁的线程的栈中拷贝的该对象的位置(因为该对象作为了锁,首先就是共享变量,所以线程肯定访问他要拷贝到工作内存)。
- 4.当状态变成重量级锁时,内容是指向了重量级锁的指针,和上边的3基本意思一样。
- 5.当状态是GC标记,即垃圾回收时进行的GC标记时,此时什么内容都不保存。
- 6.当对象状态是可偏向时,就是下节讲的偏向锁,此时保存的内容是偏向的线程的id、时间戳、分代年龄,其中的偏向线程id跟3、4中的一样,也是得到锁的那个线程的id,只不过只有当第一个线程访问同步块时得到的锁才会使用偏向锁,因为偏向锁是想进一步优化轻量级锁,即CAS操作都不想用了,因为只有一个线程嘛,所以此时只是将Mark Word的状态改为偏向锁,并把当前线程id标记在Mark Word中,后续此线程再访问此共享变量时,就直接访问了,不需要加锁什么的,但是如果有另一个线程要进入同步块时会发现Mark Work的标记状态,此时JVM就会终止偏向锁,因为这就不适合使用了,会膨胀使用轻量级锁,先通过CAS设置Mark Word,如果成功,说明之前那个线程已经不使用了,但是因为这不是首次,所以不会再用偏向锁了,而是设置成轻量级锁,如果CAS失败,说明之前那个线程还在用,此时将会直接膨胀成重量级锁,进行同步,即阻塞挂起,直到等到锁为止。这样的一个从低消耗到高消耗的膨胀升级很有必要,能节省很大的性能。
- 2.Klass word:该部分存储的是该对象指向方法区中该对象的类信息的指针。
- 3.数组长度:这部分是只有当前对象是数组时才会有,不是数组则没有此区域。
- 2.对象体:就是用来存储对象实例的具体属性数据的。
原理:轻量级锁的使用时机,是由JVM自己判断的,其实代码中我们使用的仍然是synchronized这种重量级锁,只不过JVM基于节省资源的考虑,会进行一些自动优化,会根据判断是否有必要直接升级使用重量级锁。像自旋锁也是同理。当一个线程要获取共享变量的锁的时候,它会通过CAS操作,来检查他的Mark Word部分的数据,然后更新成当前线程的工作内存中的拷贝的该对象的Mark Word(在线程的工作内存中叫做Lock Record),就是JVM会通过CAS操作尝试将Mark Word部分更新成当前要得到锁的线程的Lock Record部分的内容(由Mark Word的状态得知,轻量级锁状态的内容就是需要指向锁记录的指针,注意跟偏向锁区分开来),如果成功,则说明得到了轻量级锁,如果失败说明另外的线程已经得到了轻量级锁,也即判定此时发生了争抢,即线程数>=2时,靠轻量级锁已经无法正常运作,所以要膨胀升级成重量级锁,然后按照重量级锁的方式进行争抢(即由内核接入强行对线程进行阻塞挂起),当然此时如果开启了自旋锁,此时就会先用自旋锁优化,然后再进入阻塞挂起抢不到锁的线程。当然轻量级锁释放锁的方式还是通过CAS更新Mark Word,将原数据替换回去,成功则释放锁成功。也即全程共需两次CAS操作,这就比重量级锁的开销小很多。5.偏向锁
目的: 这个锁其实很绕弯子,不过知道他的目的就容易理解了,他更像是为了进一步优化轻量级锁而存在的。偏向锁也是在无竞争线程的前提下,只不过轻量级锁是为了在无竞争线程环境下为了消除阻塞同步锁机制,而使用了CAS来间接加锁,而偏向锁更想追求极致,它CAS也不想用了,而是在无争抢的情况下,当第一个线程首次需要获取对象锁时,它直接将对象的Mark Word的状态设置为偏向锁,并且把当前线程的id保存到内容当中,仅此而已,后续当前线程再进入此对象的同步块时就直接使用了,这样比轻量级锁还要轻便。虽然偏向锁首次设置Mark Word时依然是通过CAS操作,但是因为是首次,所以不存在失败的情况,不同于轻量级锁的CAS需要两次执行,一次是获取锁,一次是释放锁。参数-XX:UseBiasedLocking是启动偏向锁,默认就是开启的。
总结一下锁优化的整个过程: 假设代码中使用synchronized同步块代码,那么执行时会有如下过程
- 1.偏向锁:首先JVM会将第一个线程首次获取锁时启动偏向锁。
- 2.轻量级锁:当出现线程争抢共享变量时,此时JVM会将偏向锁膨胀到轻量级锁,如果轻量级锁CAS成功,那么则继续执行线程;如果CAS失败,则JVM判断是否其他线程正在使用共享变量的锁,如果是则直接膨胀使用阻塞同步锁。
- 3.自旋锁:当轻量级锁被膨胀成重量级锁时,会先启用自旋锁进行空循环等待,如果等到了则直接运行,如果一定时间内等不到,则直接执行内核态切换让当前线程阻塞挂起。【默认自旋次数=10,注意默认的新生代分代年龄次数是15,不要弄混】
- 4.重量级锁:阻塞挂起后,就进入synchronized的真正重量级锁的功能上了。
缺点: 并不是所有功能都开启就是好的,如果一个共享变量,一开始就会存在争抢,那么启动偏向和轻量级锁,那就是多余的,即他们的CAS是多余的,此时应该关闭这些优化功能。

