锁升级的过程
Cache Line
存储器是分层次的,离CPU越近的存储器,速度越快,每字节的成本越高,同时容量也因此越小。寄存器速度最快,离CPU最近,成本最高,所以个数容量有限,其次是高速缓存(缓存也是分级,有L1,L2等缓存),再次是主存(普通内存),再次是本地磁盘。
比如这时候有个应用程序xxx.exe
,这时候启动该程序,操作系统的加载步骤如下:
1、程序指令加载到内存当中
2、PC指令寄存器存储了下一条即将运行的指令地址,根据地址从内存中读取一条一条的计算机指令到Registers寄存器组中进行运算
3、通过ALU运算逻辑单元进行运算
4、将结果回写到内存中
一个CPU在同一个时刻只能运行一个线程的指令,当有另外一条线程申请时,它会将之前的线程运行的相关数据保存起来,然后运行另外一条线程,来回交替执行,这种行为叫做线程上下文切换(Context Switch
)。
而一个CPU中有多个PC指令寄存器时,也就等于CPU可以通过在CPU内部切换不同的PC指令寄存器来切换线程交替运行,能够更有效的提高CPU的速度。
其中的ALU交替的进行多个PC寄存器的工作时,叫做超线程,即一个ALU对应多个PC|Registers,如所谓的四核八线程。
而这时候涉及到了一个概念,Cache
,CPU到内存之间的缓存分为三层:
CPU的运行周期是内存的100倍,所以需要进行缓存来让CPU进行更高效的工作,以下是三层缓存的结构图:
局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中,因为如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
CPU到内存之间有很多层的内存,如图所示,CPU需要经过L1,L2,L3及主内存才能读到数据。从主内存读取数据时的过程如下:
1、当我左侧的CPU读取x的值的时候,首先会去L1缓存中去找x的值,如果没有,那么取L2,L3依次去找。
2、最后从主内存读入的时候,首先将内存数据读入L3,然后L2最后L1,然后再进行运算。
3、但是读取的时候,并不是只读一个X的值,而是按块去读取(跟电脑的总线宽度有关,一次读取一块的数据,效率更高)。
4、CPU读取X后,很可能会用到相邻的数据,所以在读X的时候,会把同一块中的Y数据也读进来。这样在用Y的时候,直接从L1中取数据就可以了。
读取的块就叫做缓存行cache line
。缓存行越大,局部性空间效率越高,但读取时间慢。缓存行越小,局部性空间效率越低,但读取时间快。目前多取一个平衡的值,64字节。
高速缓存其实就是一组缓存行(cache line
)的固定大小的数据块,其大小是以突发读或者突发写周期的大小为基础的。
每个高速缓存行完全是在一个突发读操作周期中进行填充或者下载的。即使处理器只存取一个字节的存储器,高速缓存控制器也启动整个存取器访问周期并请求整个数据块。缓存行第一个字节的地址总是突发周期尺寸的倍数。缓存行的起始位置总是与突发周期的开头保持一致。
缓存对齐
在java中,jdk一些涉及到多线程的类,有时候会看到类似于public volatile long p1,p2,p3,p4,p5,p6,p7;
这样的代码,有的就是做的缓存行对齐。
我们设计一个实验去验证缓存行对齐的导致的性能问题,及相关的解决后的效率问题。这里的思路是,首先,我们写一个类T,这个类里面有一个用volatile修饰的long属性的值,这个值占用8个字节。然后声明一个静态数组,包含两个元素,分别T的两个对象。然后开启两个线程,让两个线程分别给数组的第一个值和第二个值赋值,执行一百万次,看执行的耗时。
1 | public class T01_CacheLinePadding { |
假设数组中第一个值为X,第二个值为Y。左侧框内为第一个线程,执行修改X值的操作,右侧框内为第二个线程,修改Y的值。因为两个值在同一个缓存行中,所以在X值在读取的时候,同时将X值和Y值一起读入缓存。第二个线程只修改Y的值,但是同样将XY全部读入缓存。
线程1中X值发生修改后,由于使用了volatile
关键字保证了线程的可见性,第二个线程中的X值需要进行更新。而线程2修改Y的值后也需要同样的操作,但是这个更新不是必要的,而且会影响执行的效率。
所以要避免在同一个缓存行中使用多个volatile
关键字。
我们给第T的long值之前加入8个long值,由于一个缓存行的大小是64byte,8个long值刚好是64byte大小,这样Y值就会被挤到其他缓存行,这样彼此修改的时候就不会产生干扰,提高代码执行效率。
1 | /** |
两个需要同步的volatile值不处于同一块缓存行,所以强制更新时不会互相影响,执行后可以看出,第二套代码在执行的时候,代码要优于第一套代码的执行。
在单机的Disruptor
缓存行对其中,采用这种方式保证该数据cursor
一定能独自存储在一块缓存行中。
1 | public long p1, p2, p3, p4, p5, p6, p7; // cache line padding |
在JDK1.8
中,加入了@Contended
注解,通过使用注解标识这个属性用于独立的缓存行中,若要其生效需要在程序启动时加入-XX:-RestrictContended
参数。
1 |
|
合并写
当CPU执行一个写操作到内存时,它将会把数据写到离CPU最近的L1的数据缓存,如果这个时候的L1缓存没有命中(数据没缓存在L1里), 则CPU将会去L2缓存。
但是由于L2速度比较慢,有可能在写的过程中值又发生了变化,所以引入了一个Write Combining Buffer
缓冲区,Intel的CPU中,它只有4个字节。
当L1缓存没有命中时,WC 可以把多个对同一缓存行Store操作的数据放在WC中,在程序对相应缓存行(或者理解为这些数据)读之前先合并,等到需要读取时再一次性写入来减少写的次数和总线的压力。此时,CPU可以在把数据放入WC后继续执行指令,减少了很多时钟周期的浪费。
1 | public final class WriteCombining { |
第一个while
循环时,WC Buffer
一次性写入7个字节,无法一次写入,所以需要写两次,而由于WC buffer
中的4个字节填充满了后才会一次性写到L2中,所以需要一直等待下一次循环的4个字节填充。
第二个while
循环是一次写入一个WC Buffer
的大小,无需等待,所以效率更快。
volatile
volatile关键字有两个作用:
1、确保一个变量的更新对其他线程可见性
2、禁止指令重排序
1 | public class HelloVolatile { |
1 | 输出结果: |
代码通过一个flag表示来控制循环是否停止,按我们理解主线程flag变为true之后,应该循环停止,会打印出end出来,可是事实没有打印?为什么?
计算机 CPU
与 主存
交互的逻辑大致如图,CPU
的运算速度是 主存
的 100 倍左右,为了避免 CPU
被主存拖慢速度。当CPU
需要一个数据的时候:
1、会先从
L1
找,找到直接使用;2、
L1
中未找到,会去L2
中,L2
中找不到会去L3
,L3 找不到再去主存加载到L3
;3、再从
L3
加载到L2
,再从L2
加载到L1
;
这样提高的计算速度,同时也面临数据不一致问题。
如上例子,主存中现在有一个变量 flag = true
,CPU1 修改flag = false
之后,将结果放入到 L1
去,但是后续代码计算还会用到 flag,这时 CPU1 不会将flag = false
同步到主存中去。之后 CPU2
也从主存中取出变量 flag(flag = true
),CPU2 将自己计算的结果放入到 L1 中。这样就造成了数据不一致问题。MESI
缓存一致性协议就是为了解决这个问题的。
以上是计算机底层的实现原理,JAVA 在自己的虚拟机中执行,也有自己的内存模型,但不管怎么样,底层还是依靠的 CPU
指令集达到缓存一致性。JAVA 的内存模型屏蔽了不同平台缓存一致性协议的不同实现细节,定义了一套自己的内存模型。
java 虚拟机中的变量全部储存在主存中,每个线程都有自己的工作内存,工作内存中的变量是主存变量的副本拷贝(使用那些变量,拷贝那些),每个线程只会操作工作内存的变量,当需要保存数据一致性的时候,线程会将工作内存中的变量同步到主存中去。volatile
就是让线程改变了 flag 之后,回写到主存中,以达到缓存一致。
- 保证修改的值会立即被更新到主存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值;
在多线程情况下,CPU为了提高效率,会从多个线程中读取数据到CPU的缓存Cache中,而Volatile确保了线程的可见性,当CPU修改了该值之后,回写到内存后,会马上通知其他线程内存读取。
volatile的可见性只会影响修饰的对象,并不会影响修饰对象中的属性。
代码修改后:
1 | public class HelloVolatile { |
1 | 输出结果: |
CPU为什么会指令重排?
CPU为了提高指令执行效率,会在一条指令执行的过程中(如去内存读数据),去同时执行另一条指令,前提是两条指令没有依赖关系。
java 中的字节码最终都会编译成机器码(CPU 指令)执行,CPU 在保证单线程中执行结果不变的情况下,可以对指令进行指令重排已达到提高执行效率。
1 | public class T04_Disorder { |
1 | 上述代码指令重排执行顺序的可能: |
假设指令重排不会发生,那么 result
将不会打印,实际循环一定次数之后会打印 result
。
volatile
可以禁止指令重排,对volatile
修饰变量的读写访问,都不可以重排序。
volatile
为什么可以禁止重排序呢?通过修饰volatile
关键字后,其字节码会增加ACC_VOLATILE
标志,当JVM检测到时,会在执行指令的过程中增加内存屏障,内存屏障能够保证屏障两边的指令不可以重排!保障有序!
如图所示JSR内存屏障有四种,其中Store指的是写操作,Load指的是读操作,而LoadLoad
屏障表示在屏障之前的读数据指令和之后的读数据指令必须有序,不能重排。
而在JVM层面,要求Volatile
在读操作和写操作需要增加以下屏障:
在写操作之前,要求把上文的写操作执行完毕,然后执行写操作,接着才可以执行读操作。
在读操作之前,要求上文的读操作执行完毕后,然后执行读操作,接着才可以执行写操作。
而在操作系统级别,提供了内存屏障sfence、mfence、lfence
等系统原语,但是由于不具备可移植性,所以Hotspot
底层是通过执行CPU
的lock
的相关指令锁总线实现。
问题:在懒汉式的单例模式下,需要用
volatile
修饰符吗?答案:需要
饿汉式的单例模式是天生的线程安全,可以直接用于多线程而不会出现问题,但是懒汉式本身非线程安全,需要人为实现线程安全。
1 | public class LazySingleton { |
instant = new LazySingleton()
在操作编译器编译成指令后分为三步:
1、申请内存空间,这时候成员变量均是默认值
2、调用构造方法,初始化成员变量值
3、建立栈上和堆内存对象的关联关系
1 | //当我们调用构造方法时,java的底层的字节码指令如下: |
如果没有使用volatile
修饰符修饰,那么在这段过程中,可能出现指令重排序,也就是说可能先执行了步骤3的指令,再执行了步骤2的指令。那么在多线程情况下,这种情况可能会将未完全初始化的对象的作用域暴露给其他线程,其他线程使用了未完全初始化的对象时将产生问题。
锁的运用
在Java中说到锁,肯定熟悉Synchronized
关键字,synchronized关键字最主要有以下3种应用方式,下面分别介绍
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
在非静态方法中加锁同步代码块,比如加锁this对象,如下:
1 | public void lockTest() { |
也可以加锁new
出来的对象,如下:
1 | public class LockDemo { |
当然在Java中,JUC并发工具包也提供了ReentrantLock
锁,如下:
1 | public class LockDemo { |
那么ReentrantLock
锁,它是通过锁什么呢?可以跟踪ReentrantLock
的源码看看:
1 | /** |
可以看到ReentrantLock
主要是通过设置了一个volatile
关键字的state
属性,通过CAS机制去申请获得锁,修改state
属性。
那么synchronized
是锁的是什么呢?又是通过什么来标示锁状态呢?
对象组成
想要了解synchronized
锁的原理,需要先了解在JVM虚拟机中Java
对象的结构
如上图所示,java对象都是存储在堆中,一个java对象分为三个部分:对象头、实例数据、填充数据。
实例变量:指的就是对象的实例数据,数据占用的字节数不固定。如下,实例数据就在对象中占了5个字节大小。
1 | public class Demo { |
填充数据:在64位的虚拟机中,规定了java对象大小要求必须是8个字节的整数倍,所以当大小不满足8个字节的整数倍时,会自动填充。比如上图实例变量占用了5个字节,那么为了对齐数据,该对象可能需要填充3个字节数据。
对象头:对象头是对象的第一部分且是所有对象公共部分,对象头由两部分组成,分别是Mark Word
和Klass Pointer
(或者叫做Class Metadata Address
、Class Pointer
),如果对象是数组,则由三部分组成,将会多出一部分记录数组长度。
因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
案例分析
本文如无特殊说明时,对象均指普通对象,如果是数组对象,会特别指出。
先回顾一下,八大基础数据类型的内存占用情况:
Primitive Type | Memory Required(bytes) |
---|---|
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
然后通过引入jol-core
(jol=java object layout
)依赖,输出对象的字节存储结构,用以分析对象头的组成情况
1 | <dependency> |
1 | public class LockDemo { |
输出结果:
1 | cn.mastercom.lock.L object internals: |
由上述案例可以看到,对象头作为一个对象的开头存储结构,在64位的虚拟机中,对象头object header
占用了96bit(位)=12byte(字节)。
其中OFFSET
代表字节的偏移量,并且可以看到,对象头之后存储了实例数据,从偏移量OFFSET=12开始,boolean类型占用了SIZE=1(byte)。
从OFFSET=13开始,SIZE=3(byte)属于填充数据,用于补齐对象字节大小是8的整数倍,也就是说,当对象刚好等于8的整数倍时,则不需要填充补齐。
举个例子,将L类里的boolean类型(1byte)改成int(4byte)类型,则对象大小=对象头(12byte)+实例数据Int类型(4byte)=16byte,这时候已经是8的整数倍了,则不需要填充数据对齐。
一个(非数组)java对象中,一定会具备12B大小的对象头,实例数据或填充数据均可以是0B,比如:
- 12B对象头+4B实例数据=16B=8的整数倍
- 12B对象头+4B填充数据=16B=8的整数倍
如果该对象是一个数组对象的话,那么它的对象头由三部分组成:分别是Mark Word
和Klass Pointer
和数组长度。
1 | public class LockDemo { |
输出结果:
1 | [Ljava.lang.Object; object internals: |
可以看到,当对象是一个数组时,对象头将会多出4个字节用于存储数组的长度,也就是说,对象头占了16个byte的大小。
申明
在64位的虚拟机中:
1、普通对象的对象头占用了96bit(位)= 12byte(字节)
2、对象头大小 = Mark Word(64bit) + Klass Pointer(32bit) = 96bit = 12byte
3、数组对象的对象头占用了128bit(位) = 16byte(字节)
4、数组对象头大小 = Mark Word(64bit) + Klass Pointer(32bit) + 数组长度(32bit) = 128bit = 16byte 。
5、其中Klass Pointer和数组长度实际上均是占用了64bit大小,由于虚拟机默认开启了指针压缩,在存储时,分别将64bit的Klass Pointer和64bit的数组长度均压缩成32bit,所以Klass Pointer和数组长度的实际存储空间均是32bit。
6、一个对象占用的最小内存是16byte。
7、静态属性不算在对象大小内
在32位的虚拟机中:
1、在32位的虚拟机中,普通对象的对象头占用了64bit(位)=8byte(字节)。
2、对象头大小 = Mark Word(32bit) + Klass Pointer(32bit) = 64bit = 8byte
3、数组对象的对象头占用了96bit(位) = 12byte(字节)
4、数组对象头大小=Mark Word(32bit) + Klass Pointer(32bit) + 数组长度(32bit) = 128bit = 12byte
通过执行以下命令输出java的默认运行参数:
其中能够看到JDK在运行时默认开启了UseCompressedClassPointers
和UseCompressedOops
指针压缩,即最终存储Klass Pointer
时将会压缩成4个字节。
其中上文的Oops
全称是Ordinary Object Pointers
,即普通对象指针,比如当一个对象成员变量是一个引用数据类型,其指针指向另外一个对象时,该指针的大小,默认也是压缩成4个字节。
对象头
在JVM虚拟机规范中规范了对象头的定义:
每个GC管理的堆对象开头的公共结构。(每一个oop指针都指向一个对象头。)包括对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息,由两个词组成(在Hotspot虚拟机中,分别是Mark Word和Klass Pointer),在数组中,他后面紧跟着一个长度字段,Java对象和VM内部对象都有一个通用的对象头格式。
Klass Pointer存储的是一个指针,描述的是该对象属于哪一个类的相关信息。
Mark Word
在存储格式如下:
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。
对象拥有五种状态:无锁、偏向锁、轻量级锁,重量级锁、GC标志。
其中不同的状态分别对应不同的锁标识位,如下:
锁状态 | 是否偏向锁 | 锁标志位 |
---|---|---|
无锁 | 0 | 01 |
偏向锁 | 1 | 01 |
轻量级锁 | 00 | |
重量级锁 | 10 | |
GC标志 | 11 |
无锁和偏向锁的锁标识位均是01,通过是否偏向锁的信息判断锁的状态。
其中偏向锁和轻量级锁是JDK 1.6 对synchronized
锁进行优化后新增的,这里面的重量级锁也通常说的是synchronized
的对象锁。
在JDK 1.6以后的版本中,处理同步锁时存在锁升级的概念,JVM对同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁、最终升级到重量级锁。
这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
无锁状态
当一个对象被创建时,它将处于无锁状态:
32位虚拟机:
锁状态 | 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁 | 对象HashCode | 对象分代年龄 | 0 | 01 |
64位虚拟机:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
---|---|---|---|---|---|---|
无锁 | unused | 对象HashCode | unsused | 对象分代年龄 | 0 | 01 |
可以看到,在32位虚拟机中,无锁状态的对象,前25bit存储了对象的HashCode
,跟着4bit存储了对象的分代年龄,接着1bit存储偏向锁信息,最后2bit存储锁标志位信息。
在64位虚拟机中,无锁状态的对象,前25bit未被使用,跟着31bit存储了对象的HashCode
,接着1bit未被使用(某种场景下被用作cms_free
),之后的存储同上。
对象HashCode(identitry_hashcode):是当前对象存储在内存地址中的值
对象分代年龄(age):在常规的策略中,一个对象创建后将存放在新生代中,度过了15次Young GC之后,将会存储到老年代中,而对象分代年龄则标记着当前对象经历过几次GC。由于对象分代年龄占了4bit,其范围是【0000 - 1111】,也就是【0 -15】,也正是如此,新生代升级老年代的默认年龄由此得来。
是否偏向锁(biased_lock):对象是否启用偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock 和 biased_lock共同表示对象处于什么锁状态
锁标志位(lock):由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了 lock标记。该标记的值不同,整个 Mark Word表示的含义不同。biased_lock 和 lock一起,表达的锁状态含义。
在上文中解释了定义好的Mark Word
格式,在64位虚拟机的环境中,我们通过代码输出对象头来分析一下是否与之一致。
1 | public class LockDemo { |
1 | 输出结果: |
可以看到,前8个字节的数据,分别占了两行,但是结果集和预想的不太一样,如果按照格式规定,应该有31位的HashCode,但是从结果上都是0,并没有发现所谓的HashCode,这是为什么呢?
因为HashCode存储的是地址,地址需要通过计算得出,通过观察hashCode方法,发现是个public native int hashCode();
本地方法,底层通过C++
实现计算出来的。
我们通过调度hashCode()
方法计算对象的Hashcode
后,才会将Hashcode
存储在对象头中,这时候再观看一下对象头的变化
1 | public class LockDemo { |
可以很明显得看到了数据发生了变化,但是怎么去观看这些二进制数据呢?
这里需要引入一个字节顺序的概念,它和操作系统有关,在Linux
操作系统上,它的字节顺序是是大端存储,在Windows中,它的字节顺序是小端存储,本案例处于Windows操作系统中。
如 二进制数值【10000000 00010000】,在小端存储中,低位(值)对低位(地址),高位对高位,也就是变成 【00010000 10000000】,换句话说,上文输出的对象头格式,应该倒过来看。
从偏移量OFFSET=8,SIZE=4的信息,表示的是Klass Pointer
的信息,VALUE
列下方的数值输出的是十六进制的数据,我们代码输出的HashCode也转成十六进制,接着倒着看OFFSET=4的信息,有25bit未被使用,接着可以看到选中区域就是存储了HashCode。如图所示。
最开始的【00000001】,也正如上图Mark Word
格式所描述相同,如下:
1bit | 4bit | 1bit | 2bit |
---|---|---|---|
unused | age | biased_lock | lock |
也正是这样,可以看到,当前对象处于无锁状态,其对应的锁标志位是【01】。
1 | public class LockDemo { |
输出:
1 | cn.mastercom.sync.L object internals: |
通过输出加锁前和加锁后的对象信息,观察其锁变化,能够发现,加了锁之后,它的锁标志位指向的是轻量级锁,那么偏向锁哪里去了呢?
是因为JVM在启动的时候默认前四秒钟不启动偏向锁,通过BiasedLockingStartupDelay
参数控制,其默认值是4秒。
1 | public class LockDemo { |
1 | cn.mastercom.sync.L object internals: |
通过线程休眠了5秒之后,就能看到创建的对象采用了偏向锁。
CAS
CAS:Compare and Swap
,即比较再交换。
jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。
CAS是一种无锁算法,CAS执行CPU指令的过程时是一个原子操作,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
多线程情况下,通常使用AtomicInteger
来计算自增操作,其中就使用了CAS算法进行实现。
1 | /** |
1 | //Unsafe类 |
AtomicInteger.incrementAndGet
的实现用了乐观锁技术,调用了类sun.misc.Unsafe
库里面的 CAS算法,该方法是一个本地方法,由C++实现的逻辑,通过调用CPU(lock_cmpxchg
)指令来实现无锁自增。所以,AtomicInteger.incrementAndGet
的自增效率很高。
1 | //expect 期望的旧值 |
ABA问题
1 | //原子引用类 用于对象的CAS操作 |
在基础数据类型中,发生ABA问题是可以忽略的,因为基础数据类型是无状态的,而一个对象,解决ABA问题的关键,在于对CAS的对象进行标识版本号,而JUC包提供了一个类AtomicStampedReference<V>
1 | //initialRef 初始值 |
1 | Object o = new Object(); |
锁的升级过程
偏向锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏向锁的加锁步骤:
- Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致.
- 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码.
- 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。
- 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
当发生锁竞争时,偏向锁会变为轻量级锁,这时需要先将偏向锁进行锁撤销,这一步骤也会消耗不少的性能,轻量级锁的Mark Word中,lock标志位为00,其余内容被替换为一个指针,指向了栈里面的锁记录。
锁撤销的过程如下:
- 在一个安全点停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Markword,使其变成无锁状态。
- 唤醒当前线程,将当前锁升级成轻量级锁。
如果计算过对象的hashCode,则对象无法进入偏向状态!
轻量级锁重量级锁的hashCode存在与什么地方?
答案:线程的栈帧中,轻量级锁的LockRecord中,或是代表重量级锁的ObjectMonitor的成员中
关于epoch: (不重要)
批量重偏向与批量撤销渊源:从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
原理以class为单位,为每个class维护解决场景批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。
一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
轻量级锁
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。
轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
轻量级锁也被称为自旋锁或无锁,原因在于它在循环等待获得锁的线程释放该锁。
轻量级锁的加锁步骤:
- 线程在自己的栈桢中创建锁记录LockRecord。
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
- 将锁记录中的Owner指针指向锁对象。
- 将锁对象的对象头的MarkWord替换为指向锁记录的指针。
轻量级锁主要有两种:自旋锁和自适应自旋锁。自旋锁会导致空耗CPU且很可能锁不公平;自适应是指根据上一次该线程是否成功或者多久获取过该锁设置旋转次数,若上次失败很可能直接进入重量级锁。
重量级锁
重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁,也称为阻塞同步、悲观锁、互斥锁。其lock标志位为10,Mark Word其余内容被替换为一个指向对象监视器Monitor
的指针。特殊的是,如果此对象已经被GC标记过,lock会变为11,不含其余内容。
当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
偏向锁、轻量级锁都是JVM在用户态上进行资源分配的一种手段,而当申请了重量级锁,代表着JVM虚拟机向操作系统的内核态申请了加锁,将控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。
Monitor
当发生重量级锁(synchronized
)时,Mark Word其余内容被替换为一个指向对象监视器Monitor
的指针。而Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象,主要特点是:
- 对象的所有方法都被“互斥”的执行。好比一个Monitor只有一个运行“许可”,任一个线程进入任何一个方法都需要获得这个“许可”,离开时把许可归还。
- 通常提供singal机制:允许正持有“许可”的线程暂时放弃“许可”,等待某个谓词成真(条件变量),而条件成立后,当前进程可以“通知”正在等待这个条件变量的线程,让他可以重新去获得运行许可。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor
实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
1 | ObjectMonitor() { |
每个对象都有一个Monitor对象相关联,Monitor对象中记录了持有锁的线程信息、等待队列等。Monitor对象中有几个关键属性:
_owner:记录当前持有锁的线程
_EntryList:存放处于等待锁block状态的线程队列
_WaitSet:存放处于wait状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
1、当一个线程需要获取 Object 的锁时,会被放入
EntrySet
中进行等待,如果该线程获取到了锁,成为当前锁的 owner。其余线程会进入阻塞队列EntryList
中。2、如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入
WaitSet
队列中阻塞进行等待。3、当线程释放锁时,Owner会被置空,公平锁条件下,
EntryList
中的线程会竞争锁其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntryList 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。
锁代码块
Monitor可以存储在任意一个Java对象的对象头Mark Word
中,synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
1 | public class SyncCodeBlock { |
编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):
1 | public class com.concurrencys.SyncCodeBlock |
主要关注字节码中的如下代码:
1 | 3: monitorenter //进入同步方法 |
Java虚拟机的指令集中有monitorenter
和monitorexit
两条指令来支持synchronized
关键字的语义。方法中调用过的每条monitorenter
指令都必须执行其对应的monitorexit
指令,无论这个方法是正常结束还是异常结束。
1、执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权。
2、当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。
3、如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。
4、倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。
为了保证方法异常时monitorenter
和monitorexit
指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器生命可处理所有的异常,它的目的就是用来执行monitorexit
指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
锁方法
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED
访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先持有monitor
, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor
。
在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor
。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor
将在异常抛到同步方法之外时自动释放。
1 | public class SyncMethod { |
1 | public class com.zejian.concurrencys.SyncMethod |
synchronized
修饰的方法并没有monitorenter
指令和monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized
锁在同步代码块和同步方法上实现的基本原理。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
1 | public void add(String str1, String str2) { |
如StringBuffer
的append是一个同步方法,但是在add方法中的StringBuffer
属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer
不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
1 | public void doSomethingMethod(){ |
1 | public void doSomethingMethod(){ |
可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。
需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。
线程中断
1 | /中断线程(实例方法) |
一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()
方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:
1 | public class InterruputSleepThread3 { |
如上述代码所示,我们创建一个线程,并在线程中调用了sleep方法从而使用线程进入阻塞状态,启动线程后,调用线程实例对象的interrupt方法中断阻塞异常,并抛出InterruptedException异常,此时中断状态也将被复位。除了阻塞中断的情景,我们还可能会遇到处于运行期且非阻塞的状态的线程,这种情况下,直接调用Thread.interrupt()
中断线程是不会得到任响应的,如下代码,将无法中断非阻塞状态下的线程:
1 | public class InterruputThread { |
虽然我们调用了interrupt方法,但线程t1并未被中断,因为处于非阻塞状态的线程需要我们手动进行中断检测并结束程序,改进后代码如下:
1 | public class InterruputThread { |
代码中使用了实例方法isInterrupted
判断线程是否已被中断,如果被中断将跳出循环以此结束线程,注意非阻塞状态调用interrupt()
并不会导致中断状态重置。
综合所述,可以简单总结一下中断两种情况:
一种当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位。
另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码。
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
本篇文章主要参考:
最后更新: 2021年02月04日 20:18
原始链接: https://midkuro.gitee.io/2020/09/22/synchronized-process/