Java同步互斥访问一(synchronized)
1、前置概念
1.1、什么是同步互斥访问?
在多线程编程中,通常会有多个线程同时访问一个资源的情况,同步互斥访问就是在同一时间只能有一个线程对同一资源进行访问。
1.2、Java中实现同步互斥访问的方法
同步互斥访问的解决办法是设计一个同步器,对多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是: 对象、变量、文件等
同步器采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临 界资源。
共享:资源可以由多个线程同时访问。
可变:资源可以在其生命周期内被修改。
Java中目前有 synchronized 和 Lock (ReentrantLock)。
1.3、Java中为什么要提供两种同步器(synchronized 和 Lock)
synchronized在1.5版本时的状况:这是因为在jdk1.5版本的时候,jdk官方就提供出了 synchronized 锁,但是在1.5版本的时候,synchronized 锁的加锁方式只有一个,就是通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低,也就是比较消耗性能。
Lock锁的出现:由于 synchronized 锁的性能不大好,加的锁都是重要级别的锁,涉及到线程之间的状态切换,要从用户态切换到内核态,所以就有一个人设计了Lock锁,在当时,Lock锁的性能要比 synchronized 好很多。
synchronized锁的优化:后来jdk官方就对synchronized锁进行了优化,成了现在这个样子,性能基本和Lock差不多了。
如下图所示:
2、synchronized
2.1、静态方法上加锁和普通代码块加锁的区别
静态代码块加synchronized锁
相当于对实例化的 this 加上了锁
代码示例如下:
public class Juc_LockOnObject {
public static Object object = new Object();
private Integer stock = 10;
public void decrStock(){
//T1,T2
synchronized (object){
--stock;
if(stock <= 0){
System.out.println("库存售罄");
return;
}
}
}
}
上面是代码,我们编译程字节码文件文件之后就会出现下图的样子,Jvm会给我们加上 monitorenter 和 monitorexit ,monitorexit 有三个,这是Jvm在解锁时做一个容错(异常)处理,如下图所示:
静态方法上加 synchronized
相当对 类.class 文件加上了锁。
代码示例如下:
public class Juc_LockOnClass {
static int stock;
public static synchronized void decrStock(){
System.out.println(--stock);
}
public static synchronized void cgg(){
System.out.println();
}
public static void main(String[] args) {
//Juc_LockOnClass.class对象
Juc_LockOnClass.decrStock();
}
}
当我们输出上面代码的字节码之后就可以看到,在同步的方法上加上了 ACC_SYNCHRONIZED 关键字,这个标识jvm底层识别到之后也就会给代码块加上monitornter 和 monitorexit,如下图所示:
2.2、synchronized锁的信息在对象的什么地方
synchronized 锁的信息一般存储在对象的对象头中,对象头里面有一个Mark Word,如果是32位系统的话,是占4个字节的,对象头如下图所示:
Mark Word在不同的锁中存储的东西也是不相同的,如下图所示:
2.3、通过mark word看synchronized锁
在这里我们需要先导入一个看mark word的包,pom文件如下:
<dependencies>
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
</dependencies>
1、在不加锁的情况下,观察,代码示例如下:
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
我们执行代码,打印出下面的信息:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
首先,offset[0,4]这块是我们的mark word,我们可以看到后面的二进制码如下所示:
00000001 00000000 00000000 00000000
注意:上面这个二进制码我们不能直接看,是因为我们的windows和linux都是小端模式,(这个东西分为大端模式和小端模式),所以当为小端模式的时候,应该把这四个二进制码反过来看,如下所示:
00000000 00000000 00000000 00000001
结论:所以我们看到了最后三位数字为 001 ,对照上面的Mark Word表格,说明现在对象是一个无锁态的状态。
2、(不加延迟时)在加锁的情况下,观察,代码示例如下:
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
执行上面的代码之后会出现下面的结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) d8 f2 8c 02 (11011000 11110010 10001100 00000010) (42791640)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
注意:我们会惊奇的发现上面的锁状态是轻量级锁。
解释:这是因为Jvm在启动的过程中,也会启动十几个线程,这些线程之间会存在内部竞争,所以Jvm为了防止锁升级而消耗资源,就推迟了偏向锁的启动,会先启动轻量级锁,一般会有个4s左右的延迟。
3、(加延迟时)在加锁的情况下,观察,代码示例如下:
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
执行程序后,显示如下:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 28 4d 03 (00000101 00101000 01001101 00000011) (55388165)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从上面我们就可以看出,当延迟了5s之后,就加上了偏向锁。
4、这里有一个名词需要注意,那就是可偏向状态
可偏向状态指的是,预先做好准备,可以做偏向,但是现在还不是偏向锁的时候
代码如下:
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
当我们的代码这样写时,就出现了可偏向状态(也叫匿名偏向),如下图所示;
2.4、测试 synchronized 锁的升级
1、偏向锁向轻量级锁的升级
代码示例如下:
public class T0_BasicLock {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object o = new Object();
// 只有一个线程在用到对象 o,所以是偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
// 只有一个线程在用到对象 o,所以是偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 只有一个线程在用到对象 o,所以是偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
// 有两个线程用了到对象 o,升级到了轻量级锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
}
执行上面代码之后,我们会发现前三个都是偏向锁,最后一个当两个线程同时访问一个对象时,就变成了轻量级锁,如下图所示:
2、轻量级锁向重量级锁的升级
代码示例如下:
public class T0_heavyWeightMonitor {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object a = new Object();
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread1 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
//让线程晚点儿死亡,造成锁的竞争
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread2 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start();
thread2.start();
}
}
上面代码执行完成之后,我们会发现打印的两个锁都是重量级锁,这是因为轻量级锁在执行的过程中,如果有资源争抢的情况,会自己进行自旋(spin,就相当于执行空循环),当然这个自旋有一定的次数,我们在程序里面睡眠了2s,所以自旋的次数已经已经达到了,所以CPU认为就是抢占资源比较严重的情况,就自己将轻量级锁升级成了重量级锁。
2.5、synchronized锁的升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
3、synchronized锁中的一些名词解释
3.1、无锁状态
顾名思义,就是这个对象还没有加锁的状态。
3.2、偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。但当有多个线程同时访问对象时,并且竞争不是特别激烈(激烈意思就是其它线程不用等很久,比如说一两秒那种)时,就会升级成轻量级锁。
总结:也就是当一个对象只有一个线程进行访问时,它的锁就是偏向锁。
默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
3.3、轻量级锁
当有多个线程同时访问被加锁的对象时,偏向锁会首先升级为轻量级锁,轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞 争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场 合,就会导致轻量级锁膨胀为重量级锁。
总结:当多线程竞争不是很激烈(激烈意思就是其它线程不用等很久,比如说一两秒那种),就会是轻量级锁,否则,就升级为重量级锁。
3.4、重量级锁
是OS的一个mutex锁,非常消耗性能,也是一种互斥锁,由操作系统维护。
3.5、自旋锁
由于一般情况下锁的等待都会很短,而将线程挂起与激活都需要状态切换(用户态到内核态之间的切换),这个状态切换是非常消耗性能的,所以当已知在等很短的时间的时候,再切换状态是很得不偿失的,所以JVM会让当前的线程自己做几个空循环,可能是50个或者100个(这也就是自旋的由来),当在这个自旋的过程中获取到了锁,就去执行相应的业务逻辑,如果没有获取到,就将线程挂起。
3.6、锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时 进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以 节省毫无意义的请求锁时间,
总结:锁消除是Jvm通过上下文的扫描之后,通过逃逸分析这个锁对象不会有公共资源的竞争,就会进行锁的消除。
3.7、逃逸分析
分析当前的锁对象会不会逃出当前线程的控制范围,比如说,方法里面的局部变量,就不会逃出当前线程的范围,当前线程栈销毁后,就会销毁那个局部变量。
10、辅助知识
10.1、synchronized 三个锁阶段的hashcode分别存储在哪
1、偏向锁
可能是实时计算的,可能没有存储,因为当一个对象在拥有偏向锁时,你去调用它的hashcode方法,它会升级成轻量级锁,代码示例如下:
public class Juc_PrintMarkWord {
public static void main(String[] args) throws InterruptedException {
// 需要sleep一段时间,因为java对于偏向锁的启动是在启动几秒之后才激活。
// 因为jvm启动的过程中会有大量的同步块,且这些同步块都有竞争,如果一启动就启动
// 偏向锁,会出现很多没有必要的锁撤销
Thread.sleep(5000);
T t = new T();
//未出现任何获取锁的时候
System.out.println(ClassLayout.parseInstance(t).toPrintable());
synchronized (t){
// 获取一次锁之后
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
// 输出hashcode
System.out.println(t.hashCode());
// 计算了hashcode之后,将导致锁的升级
System.out.println(ClassLayout.parseInstance(t).toPrintable());
synchronized (t){
// 再次获取锁
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
}
}
class T{
int i = 0;
}
上面的代码执行之后,我们就会发现在调用了Hashcode方法之后,偏向锁就会升级成轻量级锁,如下图所示:
2、轻量级锁
hashCode存储在本地线程栈里面
3、重量级锁
hashCode存放在minitor中