前言
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,这种切换会消耗大量的系统资源,因为用户态和内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递许多变量、参数给内核,内核也需要保护用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。如果线程切换是一个高频操作,这将会消耗很多CPU处理时间,如果对于那些需要同步的简单代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕。synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是Java语言中一个重量级的同步操作,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认开启了自旋锁,它们都属于乐观锁。明确Java线程切换的代价,是理解Java中各种锁的优缺点的基础之一。
锁从宏观上分类,分为乐观锁与悲观锁。乐观锁认为读多于写,遇到并发的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本,然后加锁操作(比较跟上一次的版本号,如果一样就更新),如果失败则要重复读-比较-写操作。Java中的乐观锁基本都是通过CAS操作实现的。悲观锁认为写多于读,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人向读写这个数据就会block直到拿到锁。Java中的悲观锁就是synchronized。
Java中有四种锁,分别是重量级锁、偏向锁、轻量级锁和自旋锁。重量级锁属于悲观锁的一种,后三者则属于乐观锁。
重量级锁synchronized
1、synchronized的作用
在JDK1.5之前都是使用synchronized关键字保证同步的,synchronized可以把任意一个非NULL对象当作锁。
- 作用于方法时,锁住的是对象的实例(this)
- 作用于静态方法时,锁住的是Class实例,因为Class的相关数据存储在永久代,永久代是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
- 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块
2、synchronized的实现
synchronized的实现如上图所示,它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
- ContentionList:竞争队列,多次请求锁的线程首先被放在这个竞争队列中
- EntryList:ContentionList中那些有资格成为候选资源的线程被移动到EntryList中
- WaitSet:哪些调用wait方法被阻塞的线程被放置在这里
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
- Owner:当前已经获取到锁资源的线程被称为Owner
- !Owner:当前释放锁的线程
JVM每次从队列的尾部取出一个数据用于竞争候选者(OnDeck),但是并发情况下,ContentionLIst会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。OnDeck线程获取到锁资源后会变成Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该状态是由操作系统来完成的。synchronized是非公平锁,synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
偏向锁
Java偏向锁是Java6引入的一项多线程优化,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
1、偏向锁的实现
偏向锁获取过程
① 访问Mark Word中偏向锁的标识是否设置为1,锁标志是否为01,确认为可偏向状态
② 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5
③ 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行步骤5,如果竞争失败,执行步骤4
④ 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码
⑤ 执行同步代码
偏向锁的释放
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁,偏向锁的撤销,需要等待全局安全点,它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。
2、偏向锁的适用场景
始终只有一个线程在执行同步代码块,在它没有执行完释放锁之前,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the world操作,导致性能下降,这种情况下应当禁用
轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
1、轻量级锁的加锁过程
① 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为01,偏向锁标志位为0),虚拟机首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
② 拷贝对象头中的Mark Word复制到锁记录中
③ 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5
④ 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为00,即表示此对象处于轻量级锁状态,这时候线程堆栈与对象头的状态如图所示:
⑤ 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程
自旋锁
1、自旋锁实现原理
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等待有锁的线程释放锁后即可立即获取锁,这样就避免用户态和内核态的切换的消耗。但是线程自旋是需要消耗CPU的,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
2、自旋锁的优缺点
自旋锁尽可能地减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用执行不同块,这时候就不适用使用自旋锁,因为自旋锁在获取锁前一直都是占用CPU做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其他需要CPU的线程又不能获取到CPU,造成CPU的浪费。
3、自旋锁时间阈值
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而影响整体系统的性能,因此自旋的周期额外重要。
JVM对于自旋周期的选择,JDK1.5这个限度是一定要写死的,在JDK1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自选时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化。
- 如果平均负载小于CPUs则一直自旋
- 如果有 超过(CPUs / 2)个线程正在自旋,则后来线程直接阻塞
- 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间或进入阻塞
- 如果CPU处于节电模式则停止自旋
- 自旋时间的最坏情况是CPU的存储延迟
- 自旋时会适当放弃线程优先级之间的差异
锁优化
1、减少锁的时间
不需要同步执行的代码,能不放在同步块里面执行就不要放在同步块内,可以让锁尽量释放。
2、减少锁的粒度
将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。
3、锁粗化
大部分情况下我们要让锁的粒度最小化,锁的粗化则是要增加锁的粒度。假设有一个循环,循环内的操作需要进行加锁,我们应该把锁放在循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的。
4、使用读写锁
ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。
5、消除缓存行的伪共享
- 在JDK1.7之前会将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行
- 在JDK1.7中,因为JVM会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现
- 在JDK1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在JVM中添加以下参数:-XX:-RestrictContended。sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离
总结
上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候JVM会根据启用的锁和当前线程的争用情况,决定如何执行同步操作。
在所有的锁都启用的情况下线程进入临界区会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,如果以上两种都失败,则启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的进行将阻塞挂起,直到持有锁的线程执行完同步代码块唤醒它们。
偏向锁是在无锁争用的情况下使用的,也就是同步在当前线程没有执行完之前,没有其他线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,一旦有两个以上的线程争用,就会升级为重量级锁。