synchronized底层实现原理&CAS操作&偏向锁、轻量级锁,重量级锁、自旋锁、自适应自旋锁、锁消除、锁粗化

进入时:monitorenter

每个对象有一个监视器锁(monitor)当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果该线程已经占有该monitor,又重新进入,则进入monitor的进入数加1。
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。


退出时:monitorexit

执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个
monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出synchronized的实现原理,synchronized的语义底层是通过一个monitor的对象来完成。
其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

当synchronized加在方法前时:

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(其实也可以通过这两条指令来实现)。

相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。

JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
---------------------
以上转自:https://blog.csdn.net/hbtj_1216/article/details/77773292 

 

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

 

 

JVM中synchronized的优化实现

一、基础知识

1.对象头中的Mark Word数据结构

 

上图是java对象在堆中的结构,其中对象头是我们本次关注的重点。

synchronized用的锁是存在Java对象头里的?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述。

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化(依据锁标志位和是否偏向锁进行判断),变化状态如下(32位虚拟机):

2.CAS操作(非阻塞同步)

2.1概念

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁.而CAS操作(又称为无锁操作)是一种乐观锁策略.它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作.因此,线程就不会出现阻塞停顿的状态.那么,如果出现冲突怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突重试当前操作直到没有冲突为止.

CAS的实现需要硬件指令集的支撑,在JDK1.6及之后虚拟机才可以使用处理器提供的CMPXCHG指令实现.

2.2操作过程

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程

2.3 CAS的应用场景

在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现.

2.4. ABA问题 

因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题.这里存在这样一个有意思的问题.比如一个旧值A变为成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化.解决方案可以沿袭数据库常用的乐观锁方式,添加一个版本号时间戳可以解决.原来的变化路径 A->B->A 就变成了 1A->2B->3C. java这么优秀的语言,当然在java1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的.

2.5.只能保证一个共享变量的原子操作

当对一个共享变量执行操作是CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不性能保证其原子性.有一个解决方案就是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量.然后将这个对象做CAS操作就可以保证其原子.atomic中提供了AtomicReference来保证引用对象之间的原子性.

 

二、synchronized的优化

对于synchronized这个关键字,在jdk1.5及之前,他是一个重量级锁,开销很大,建议大家少用点。但到了jdk1.6之后,该关键字被进行了很多的优化,已经不像以前那样不给力了,建议大家多使用。

(1)在jdk1.6中对于synchronized的实现,JVM中引入了偏向锁、轻量级锁,重量级锁(自旋锁--JDK1.4.2引入,自适应自旋锁,锁消除,锁粗化)等方法和概念,对synchronized锁的实现进行了优化。

(2)synchronized中的锁一般分为重量锁(对象锁),自旋锁,自适应自旋锁,轻量锁,偏向锁

自旋锁的应用场景:

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。 

若一个线程等待获取锁对象所持续的时间非常短,这时适合使用自旋锁。所谓自旋锁,就是等待锁的线程并不进入阻塞状态,而是执行一个无意义的循环。在循环结束后查看锁是否已经被释放,若已经释放则直接进入执行状态。因为长时间无意义循环也会大量浪费系统资源,因此自旋锁适用于间隔时间短的加锁场景。

自适应自旋锁对自旋次数的调整:

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 

锁消除

有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 

有时我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,它们的内部实现存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法。

public void vectorTest(){
        Vector<String> vector = new Vector<String>();
        for(int i = 0 ; i < 10 ; i++){
            vector.add(i + "");
        }
        System.out.println(vector);
    }

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。 
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

 

轻量锁和偏向锁

适用于没有线程竞争的情况。无法代替重量锁

重量级锁:

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

 

(3)上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,如果以上两种都失败,则启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;

偏向锁--》轻量级锁--》自旋锁--》重量级锁

 

 

轻量锁与偏向锁的不同:

 

  1. 轻量锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
  2. 轻量锁每次进入/退出同步块都需要CAS更新对象头
  3. 争夺轻量级锁失败时,自旋尝试抢占锁

 

      可以看到轻量锁适合在竞争情况下使用,其自旋锁可以保证响应速度快,但自旋操作会占用CPU,所以一些计算时间长的操作不适合使用轻量级锁。

==>可以认为 自旋锁 是轻量锁执行中的一部分

 

 

 

(4)偏向锁是在无锁争用的情况下使用的,也就是同步块在当前线程没有执行完之前,没有其它线程会执行该同步快,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,一点有两个以上线程争用,就会升级为重量级锁;

(5)如果线程争用激烈,那么应该禁用偏向锁。


偏向锁的获取和释放流程  及 轻量级锁的获取和释放/膨胀过程 请参考  https://blog.csdn.net/shandian000/article/details/54927876

不同锁的比较

 

锁的公平与不公平
公平锁是指线程获得锁的顺序按照FIFO的原则,先排队的先得。非公平锁指每个线程都先要竞争锁,不管排队先后,所以后到的线程有可能无需进入等待队列直接竞争到锁。非公平锁虽然可能导致某些线程饥饿,但是锁的吞吐率是公平锁好几倍,synchronized是一个典型的非公平锁方案,而且没法做成公平锁。

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

 

三、程序中可以进行的锁优化

以上介绍的锁优化是JVM自动控制的,不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

1.减少锁的时间

不需要同步执行的代码,能不放在同步快里面执行,就不要放在同步块内,可以让锁尽快释放;

2.减少锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;

java中很多数据结构都是采用这种方法提高并发操作的效率,例如:ConcurrentHashMap、LongAdder、LinkedBlockingQueue

ConcurrentHashMap

java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组: Segment< K,V >[] segments

Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

LongAdder

LongAdder 实现思路也类似ConcurrentHashMap,LongAdder有一个根据当前并发状况动态改变的Cell数组,Cell对象里面有一个long类型的value用来存储值; 
开始没有并发争用的时候或者是cells数组正在初始化的时候,会使用cas来将值累加到成员变量的base上,在并发争用的情况下,LongAdder会初始化cells数组,在Cell数组中选定一个Cell加锁,数组有多少个cell,就允许同时有多少线程进行修改,最后将数组中每个Cell中的value相加,在加上base的值,就是最终的值;cell数组还能根据当前线程争用情况进行扩容,初始长度为2,每次扩容会增长一倍,直到扩容到大于等于cpu数量就不再扩容;

LinkedBlockingQueue

LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;

注意:拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量个锁即可;

3.锁粗化

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度; 

在以下场景下需要粗化锁的粒度: 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的

3.使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

4.消除缓存行的伪共享(基本由JVM实现)

除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。 
在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读取数据是以缓存行为最小单元读取的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。 
例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁; 
为了防止伪共享,不同jdk版本实现方式是不一样的: 
1. 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行; 
2. 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现; 
3. 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数: 
-XX:-RestrictContended

sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离; 
关于什么是缓存行,jdk是如何避免缓存行的,网上有非常多的解释,在这里就不再深入讲解了;
---------------------
以上参考:https://blog.csdn.net/kirito_j/article/details/79201213 

posted @ 2018-11-28 12:23  庚拓天下  阅读(2019)  评论(0编辑  收藏  举报