理解锁的基本知识
1. 锁的类型
锁从宏观上分类,分为悲观锁与乐观锁。
-
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。 -
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java 中的悲观锁就是Synchronized
,AQS 框架下的锁则是先尝试 CAS 乐观锁去获取锁,获取不到,才会转换为悲观锁,如ReentrantLock
。
2. Java 线程阻塞的代价
java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,操作系统的这种切换操作称之为互斥(mutex)。
Synchronized
会导致争用不到锁的线程进入阻塞状态,所以说它是 java 语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM 从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。
3. markword
HotSpot 虚拟机的对象头包括两部分信息
- markword:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。
- klass:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
上图中,我们提到了 java 的4种锁,他们分别是重量级锁、自旋锁、轻量级锁和偏向锁。不同的锁有不同的特点,每种锁只有在其特定的场景下,才会有出色的表现,java 中没有哪种锁能够在所有情况下都能有出色的效率,引入这么多锁的原因就是为了应对不同的场景。
前面讲到了重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。下面我们具体来分析下他们的特效;
自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗 cup 的,说白了就是让 cup 在做无用功,如果一直获取不到锁,那线程也不能一直占用 cup 自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
JVM 对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。
重量级锁 Synchronized
Synchronized 的作用相信大家都已经非常熟悉了;
它可以把任意一个非NULL的对象当作锁:
- 作用于方法,锁住的是对象的实例(this);
- 当作用于静态方法时,锁住的是 Class 实例,又因为 Class 的相关数据存储在永久带 PermGen(jdk1.8则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
- synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
偏向锁
所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程 。
假如大部分情况是没有竞争的(某个同步块大多数情况都不会出现多线程同时竞争锁),那么可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时,会判断是否偏向锁指向我,那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,则当前线程会采用 CAS 操作将 markword 中线程ID设置为当前线程ID,如果 CAS 操作成功,则获取偏向锁成功,去执行同步代码块,如果 CAS 操作失败,则表示有竞争,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁。
偏向锁的实现就是将对象头 markword 的标记设置为偏向,并将线程ID写入对象头 markword,当其他线程请求相同的锁时,偏向模式结束。
JVM默认启用偏向锁,使用如下的JVM参数来设置偏向锁:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=4
BiasedLockingStartupDelay 表示系统启动几秒钟后启用偏向锁。默认为4秒,原因在于,系统刚启动时,一般数据竞争是比较激烈的,此时启用偏向锁会降低性能。
轻量级锁
轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥(mutex)的几率,并不是要替代互斥。
它采用 CAS 尝试将对象的 markword 更新为指向当前线程的指针,在进入互斥前,进行补救。
轻量级锁是由偏向锁升级来的,轻量锁存在的目的是尽可能不用动用操作系统层面的互斥,因为那个性能会比较差。因为 JVM 本身就是一个应用,所以希望在应用层面上就解决线程同步问题。
总结一下就是轻量级锁是一种快速的锁定方法,在进入互斥之前,使用 CAS 操作来尝试加锁,尽量不要用操作系统层面的互斥,提高了性能。
偏向锁升级为轻量锁的步骤为:
- 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 markword 的拷贝;
- 拷贝对象头中的 markword 复制到锁记录中;
- 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 markword 更新为指向 Lock Record 的指针;
- 如果 CAS 操作失败,表示存在竞争,升级为重量级锁,就是操作系统层面的同步方法。在没有锁竞争的情况,轻量级锁减少互斥产生的性能损耗。在竞争非常激烈时(轻量级锁(CAS 操作)总是失败),轻量级锁会多做很多额外操作,导致性能下降。
小结
synchronized 的执行过程:
- 检测 markword 里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁。
- 如果不是,则使用 CAS 将 markword 中的线程ID更新为当前线程的ID,如果成功则表示当前线程获得偏向锁。
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用 CAS 将对象的 markword 更新为指向 Lock Record 的指针,如果成功,当前线程获得锁。
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级锁状态。
- 如果自旋失败,则升级为重量级锁。
上面几种锁都是 JVM 自己内部实现,当我们执行 synchronized 同步块的时候 jvm 会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁;
锁优化
以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;
减少锁时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;
减少锁的粒度
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;
java中很多数据结构都是采用这种方法提高并发操作的效率:
ConcurrentHashMap
java中的 ConcurrentHashMap 在 jdk1.8 之前的版本,使用一个 Segment 数组,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数量就不再扩容,这也就是为什么 LongAdder 比 cas 和 AtomicLong 效率要高的原因,后面两者都是 volatile+cas 实现的,他们的竞争维度是1,LongAdder 的竞争维度为“Cell个数+1”,为什么要+1?因为它还有一个 base,如果竞争不到锁还会尝试将数值加到 base 上;
LinkedBlockingQueue
LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;
拆锁的粒度不能无限拆,最多可以将一个锁拆为当前 cup 数量个锁即可;
锁粗化
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
在以下场景下需要粗化锁的粒度:
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
使用读写锁
ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;
读写分离
CopyOnWriteArrayList 、CopyOnWriteArraySet
CopyOnWrite 容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
CopyOnWrite 并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的;
使用 cas
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用 cas 效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用 volatiled+cas 操作会是非常高效的选择;
网友评论