Java多线程-Synchronized
1.Synchronized的作用
Synchronized是同步、在时间上一致的意思。能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。
Synchronized可以保证数据的可见性,即一个线程执行一系列操作后,另一个线程获取到正确的结果。
2.Synchronized的两个用法
2.1 对象锁
2.1.1 方法锁
在方法签名上加锁,synchronized修饰普通方法,默认锁对象为当前实例对象this。
如果实例化多个Runnable接口的实现类,那么他们不会互相等待,因为synchronized默认锁对象是当前实例this,而他们现在已经是不同的实例对象了,自然this也不一样了。
1 |
|
输出结果:
1 |
|
2.1.2同步代码块锁
手动指定锁对象。也可以手动指定this对象为锁对象
为什么要手动指定呢?因为有可能在业务逻辑中多段代码都是不能同步执行的,但是他们之间又不互相干,所以要分别加锁,而不是全部只加一个锁。
1 |
|
输出结果为:
1 |
|
2.2 类锁
类锁是对于Class对象的锁。
一个java类可以有多个实例化对象,但是他们本质都是这个java类,即只有一个Class对象。
类锁的形式:
- synchronized加在这个类的static方法上
- synchronized代码块
这样就算是这个类的不同的实例对象,类锁对他们都是有效的。
synchronized代码块的例子:
1 |
|
输出结果:
1 |
|
方法抛出异常后会释放锁
3.Synchronized的性质
3.1 可重入
同一线程的外层函数获得锁之后,内层函数可以直接再次获取该锁。(一次获取,只要不放弃这个锁,可以多次利用)
好处:避免死锁,提升封装性。
解释:假设方法一被synchronized关键字修饰,方法二也被synchronized修饰,此时当线程一运行方法一拿到了这个锁,而在方法一中调用了方法二,假设不具备可重入,线程一想要去访问方法二需要使用这个锁,但是线程一不能直接使用本身已经获得的这个锁,可是线程一已经拿到了这个锁,所以这里线程一既想获得这个锁,又释放不了它,所以就死锁了。
所以可重入就避免了死锁和一次一次获取锁的操作。
原理:加锁次数计数器
jvm会记录被加锁的次数(锁对象被每一个线程获取了几次),当第一次加锁时,次数从0变为1,以后再加锁就依次加1,当退出一层同步代码块时,计数减一,当计数为0时,锁被释放。
3.2 不可中断
当一个线程获得了这个锁,其他线程必须等待它释放这个锁后才能使用它。
4.加锁和释放锁
4.1 时机
获取锁和释放锁的时机:进入和退出由synchronized修饰的方法和同步代码块(包括抛出异常)
4.2 原理
使用monitorenter获得锁,monitorexit释放锁。有两个monitorexit是因为一个是正常释放锁,一个是抛出异常后释放锁,jvm虚拟机会检查是哪种情况。
1 |
|
反编译的结果:
1 |
|
5.Synchronized的缺点
效率低:锁的释放情况少,视图获得锁时无法设定超时,不能中断一个正在试图获得锁的线程。
不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件,就是这个锁是哪个对象的锁
无法知道是否成功获取到锁
6.原理
6.1 java对象结构
java对象(Object实例)结构包括三个部分:对象头,对象体,对齐字节
6.1.2 对象头
对象头的结构:
- Mark Word
- Class Pointer
- Array Length(这个只有数组对象才有,普通对象就只有上面两个)
Mark Word
作用:用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态。
长度:与JVM的位数有关,Mark Word的长度为JVM的一个字的大小,即32位的JVM的Mark Word为32位,64位的JVM的Mark Word长度为64位。
下面我们都以64位的Mark Word为例:
java内置锁的状态有4种,级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。
从低位到高位依次表示的是:
(1)lock:锁状态标记位,2bit
(2)biased_lock:对象是否启用偏向锁标记。1bit,为1表示启用了偏向锁,其他状态下该位均为0
lock和biased_lock共同决定了是否使用偏向锁,因为偏向锁状态和无锁状态的lock为都是01,无法判断是否使用偏向锁,因此当001是无锁,101是偏向锁。
(3)age:java的分代年龄,4bit,因此最大为15,并行GC的年龄阈值为15就会晋升到老年代。
(4)hashcode:对象的哈希码,31bit,采用延迟加载技术,刚开始创建对象的时候这个参数值为空,只有当调用了计算哈希的方法后才会被写入到对象头。
(5)thread:线程id,是拥有这个偏向锁的线程id
(6)ptr_to_lock_record:在轻量级锁状态下指向栈帧中锁记录的指针
(7)ptr_to_heavyweight_monitor:在重量级锁的状态下指向对象监视器的指针。每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级锁)后,,则对象会尝试找一个Monitor与之关联,关联成功,该对象头的Mark Word中就被设置指向Monitor对象的指针。
Class Pointer
作用:类对象指针,执行方法区中的类的klass对象,jvm通过这个指针确定这个对象是哪个类的实例。
长度:与JVM的位数有关,Class Pointer的长度为JVM的一个字的大小,即32位的JVM的Class Pointer为32位,64位的JVM的Class Pointer长度为64位。
因为64位的类对象指针会占据大量内存,因此一般对该字段会采取指针压缩的方法,即Oop对象指针压缩(可以手动关闭),压缩至32位。
Array Length
只有数组对象才有这个字段。
也会指针压缩
6.1.2 对象体
包含对象的成员变量。
6.1.3 对齐字节
填充字节,保证java对象所占用的内存字节数为8的倍数。
6.2 Monitor
Monitor被翻译为监视器或管程。(操作系统层面的对象)
Monitor内部有WaitSet、EntryList(等待队列:希望获取Monitor锁,但是现在Monitor已经被其他线程所有,所以这个线程会进入这个等待队列,BLOCKED)、Owner(获得这个锁的线程指向它)
当拥有这个锁的线程把临界区代码执行完之后会释放这个锁,具体过程是按照Monitor地址找到Monitor对象,设置Owner为null,唤醒等待队列的线程,选一个线程拥有这个Monitor锁。
6.2 偏向锁
为什么要引入偏向锁?当一个同步代码块或者方法没有多个线程竞争,总是由一个线程多次重入申请获得锁,这样比较耗费性能。
偏向锁的核心原理:当锁对象第一次被线程获取时,
线程ID是由操作系统设置的,不是我们使用thread.getId得到的那个thread实例的id。
以后这个线程想要再进入同步块中,只需要判断锁对象中的线程ID和偏向标志位,就可以直接进入代码块中,不需要cas、进行申请锁等一系列操作。
如果线程ID不是当前线程的,有两种情况,1.线程ID为空
但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态,所以在4s之后创建的对象才是在匿名偏向状态,即偏向标志位为1,thread id为0
因为jvm在启动的时候需要加载资源,这些对象加上偏向锁没有意义,不起用偏向锁能减少大量偏向锁撤销的成本。
============分割线=============
我测试的偏向锁并没有延迟加载,现在可能是已经改了,都是立刻加载的,不过可以通过设置参数Run->Edit Configurations->VM options-> -XX:BiasedLockingStartupDelay=4000来开启偏向锁的延迟加载
撤销偏向
以下几种情况会使对象的偏向锁失效
- 调用对象的hashCode方法
- 其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
- 调用了wait/notify方法(调用wait方法会导致锁膨胀而使用重量级锁)
批量重偏向
假设n个对象都是thread1的偏向锁对象,但是这n个对象被多个线程访问,但没有竞争,那其他线程访问时,会将偏向锁撤销,升级成轻量级锁。
当撤销偏向锁的次数超过20次以后,这时剩下的偏向thread1的对象偏向给thread1的对象可能会重新批量重偏向给后来访问线程1的对象的线程。(因为每次撤销的消耗太大了)
当撤销偏向锁的阈值超过40以后,就会将整个类的对象都改为不可偏向的(001),新建的对象也是不可偏向的。
6.3 轻量级锁
轻量级锁使用场景:当一个对象被多个线程所访问,但访问的时间是错开的(不存在竞争),此时就可以使用轻量级锁来优化。但是如果又有了竞争,轻量级锁会升级成重量级锁。优先使用轻量级锁。
轻量级锁对使用者是透明的,语法上还是使用synchronized代码块
使用原理:创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录对象,内部可以存储锁定对象的mark word。
让锁记录中的Object reference指向锁对象(Object),并尝试用cas(原子操作,线程安全)去替换Object中的mark word,将此mark word放入lock record中保存。如果cas替换成功,则将Object的对象头替换为锁记录的地址和状态 00(轻量级锁状态),并由该线程给对象加锁。
所以替换成功的结果就是锁对象中的 lock record地址 00 与Object对象中的Make word交换。
锁重入就是该线程run方法中synchronized代码块中再次包裹synchronized代码块,即再次锁住这个对象。
如果一个线程在给一个对象加轻量级锁时,cas替换操作失败有两种情况:
- 此时其他线程已经给对象加了轻量级锁的情况,此时该线程就会进入锁膨胀过程。
- 自己执行了synchronized锁重入,那么会再加一条Lock Record对象,它的Object reference也指向这个object对象,线程中Lock Record对象的个数作为锁重入的计数。
每次锁重入的时候都需要cas,开销较大。所以引入了偏向锁。
只有第一次使用CAS将线程ID设置到对象的Mark word头中,之后如果发生了锁重入,发现这个线程ID是自己的就表示没有竞争,不用再cas,线程ID是由操作系统设置的,不是我们使用thread.getId得到的那个。
6.4 重量级锁
如果一个线程在给一个对象加轻量级锁时,cas替换操作失败(此时其他线程已经给对象加了轻量级锁的情况),此时该线程就会进入锁膨胀过程。
这个线程为Object申请Monitor锁,让Object的Mark word中保存Monitor地址,并指向Monitor,然后自己进入Monitor的EntryList中阻塞,当Thread0退出同步代码块时,需要解锁,尝试使用cas将Mark Word的值恢复给对象,失败,会进入重量级锁解锁流程。根据Object reference找到锁对象object,然后根据Mark word中的地址找到Monitor对象,设置Owner为null,唤醒等待队列的线程,选一个线程拥有这个Monitor锁。
在重量级锁竞争时,线程不会直接BLOCKED,而是先自旋重新尝试获得Monitor锁,如果在几次自旋后获得了锁,就开始执行,如果没有获得,就进入阻塞状态。
6.5 例子
创建完锁对象后,默认是匿名偏向状态。
1 |
|
小端:数据的高字节保存在内存的高地址中
大端:数据的高字节保存在内存的低地址中 //它们都是以字节为单位存放的
所以上面的数据是以小端的形式输出的,我们可以转换为正常习惯的方式,即大端来看。
对于第二个对象布局,首先,第一行和第二行是Mark Word,第三行是Class Pointer
Mark Word的大端形式(8B):00 00 00 00 00 00 00 05
转化为二进制就是 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
,所以是匿名偏向状态,即可偏向,但是还没有线程偏向于这个锁对象。
线程A先获得了锁对象,则锁对象偏向于它,这时再来一个线程B竞争,锁升级为轻量级锁
1 |
|
这里测试了好几次…..因为在锁对象状态为偏向锁的时候,有另外一个线程B来竞争锁对象,此时锁就会升级为轻量级锁,但是只要线程A不放弃锁,锁就还是它的,线程B会进行自旋,尝试获取锁,默认情况下自旋的次数为10次,如果超过10次还没有获得锁,就进行轻量级锁膨胀为重量级锁。
但是这个自旋时间也太不好把握了,稍微超过了自旋时间还没获得锁,就变成重量级锁了…..测试了半天才有一个000
轻量级锁有两种:普通自旋锁,自适应自旋锁
1.普通自旋锁:当有线程来竞争锁时,假如此时锁已经被另一个线程占用,这个抢锁线程就会在原地循环等待,而不会被阻塞,多次尝试获得锁,默认自旋次数为10次
2.自适应自旋锁:自旋次数不是固定的,而是根据1.如果抢锁线程在同一个锁对象上曾经获得过锁,那jvm就认为这次自旋很可能再次成功,因此自旋时间会更长,2.如果对于某个锁,抢锁线程很少成功获得过,那jvm就可能减少自旋时间甚至不自旋,以节约资源。
1 |
|
这没有竞争,线程A先持有锁,接着释放锁之后线程B再持有
这里其实有可能是000 也可能是101
偏向锁膨胀的步骤: