Synchronized
目录
对于synchronized,我首先提出了下面3个问题,
1.什么东西需要同步?2.为什么我们需要同步?3.我们如何去同步?
在多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。
什么是共享?->资源可以由多个线程同时访问
什么是可变?->资源可以在生命周期内被修改 (生命周期:创建出来的对象,还在被其他对象引用,还未被垃圾回收)
由于线程执行的过程是不可控的,所以需要采用同步机制来使对象可变状态变得可控。(算是上面的1,2问题了)
第3个问题,如何去同步?
所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。
即在同一时刻,有且仅有唯一的一个线程访问这个临界资源。
Java 中,提供了两种方式来实现(就是常说的锁):synchronized 和 Lock
不过有一点需要理解清楚的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,不会被其他线程所访问到,不会导致线程安全问题。(可见JVM虚拟机图)
synchronized基本概念
synchronized(内置锁,不要我们去加锁解锁,都交给JVM去处理)可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
加锁的方式:
1. 同步实例方法,锁是当前实例对象
2. 同步类方法,锁是当前类对象
3. 同步代码块,锁是括号里面的对象
synchronized底层原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。
这里有个小故事。要不要听?因为synchronized是个重量级锁,性能较低,有个一个传说级大佬,Doug Lea写了一个传奇的包,并发包,java.util.concurrent下的代码都是他写的,他通过java代码实现了锁的功能,并且性能比synchronized要好。这让Oracle公司的大佬能忍?这是在瞧不起谁?这是要一个人挑他们一群的节奏啊。于是乎,他们将synchronized进行了优化,如下:
JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
这里有一个我自己反编译的一个demo,可以参考一下:
Java代码:
public void test(){
synchronized (this){
System.out.println("加锁成功,锁对象是==>"+this);
//代码逻辑
}
}
反编译后:
public void test();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: new #3 // class java/lang/StringBuilder
10: dup
11: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
14: ldc #5 // String 加锁成功,锁对象是==>
16: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: aload_0
20: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
23: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
29: aload_1
30: monitorexit
31: goto 39
34: astore_2
35: aload_1
36: monitorexit
37: aload_2
38: athrow
39: return
为什么会有2个monitorexit,是为了防止在代码逻辑块中程序出现了异常,导致没能够在第一个monitorexit释放锁,而导致该代码块未被释放其他线程无法获取锁,第二个相当于在finally中释放锁,确保锁能够释放。
Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,加锁的过程如下:
Monitor监视器锁
我们每创建一个对象(任何对象)都有一个Monitor与之相对应相关联。当这个Monitor被一个线程持有后,那么他将处于锁住的状态。可以看下Object这个类,wait和notify等方法就依赖monitor,里面有描述monitor的。
Synchronized是内置锁,是通过jvm来控制的,jvm里就是通过进入monitor对象来实现方法的同步和代码块的同步。加锁与解锁也是由jvm来帮我们完成的。就是我上面的两张图。
Synchronized也是可重入锁,什么是可重入锁?->指一个线程获取对象锁后,在后面的逻辑中还要获取相同的对象锁,不会因为之前锁没有释放而阻塞在那,可以一定程度上的避免死锁。
注:ReentrantLock也是可重入锁,因为他是显示锁,所以需要手动的加锁解锁。
Synchronized可重入锁
Synchronized可重入是如何实现的呢!?通过monitor中的计数器(C++源码中是_count这个参数)
monitorenter:当monitor被线程持有的时候就会处于锁住的状态,monitorenter指令就会尝试的去获取monitor的所有权,过程大致如下:
1.monitor中有个计数器的东西,初始时是0,当有个线程进入monitor时,计数器加1;
2.当该线程还要锁住这个对象(即重入),再次进入monitor,计数器再加1,以此类推,释放锁时则减1;
3.此时若是由其他线程要来锁住这个对象,发现这个计数器不是初始值0 ,则阻塞在那,等到持有monitor的线程执行完,释放完所有的锁,计数器为0时,这时候再来尝试获取monitor的所有权。
monitorexit:这个就是必须有线程将monitor持有时,monitorexit指令执行则计数器减1,对应上面的步骤2,减到计数器为0的时候,该线程不在持有monitor,其他线程开始尝试获取monitor的所有权。
什么是monitor
可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。
还记得上面的小故事嘛?synchronized在jdk1.6之前是个重量级锁,性能较低。那么到底是怎么个优化法的呢?->锁的粗化,锁的消除,锁的膨胀升级。
讲锁的膨胀升级前先了解一下对象的内存布局。
对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
2.实例数据:存放类的属性数据信息,包括父类的属性信息;
3.对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
由此可见对象头的重要性,我们深入理解一下对象头。
对象头
HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。
哪些信息会被压缩?
1.对象的全局静态变量(即类属性)
2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
JavaThreadID: 保存持有偏向锁的线程ID
epoch: 保存偏向时间戳
age: 保存对象的分代年龄
biased_lock: 偏向锁标识位
lock: 锁状态标识位
hashCode: 保存对象的哈希码
对象头分析工具
运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
通过下面这句话打印出对象头的相关信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
例如下面是我打印的一个偏向锁的一个对象头信息,我标红的3位就是上面图给出来的偏向锁的后3字节。
com.cx.sync.ObjectSzieDemo$Chenxuan object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 0d d4 fc 02 (00001101 11010100 11111100 00000010) (50123789)
4 4 (object header) e8 26 63 15 (11101000 00100110 01100011 00010101) (358819560)
8 4 int Chenxuan.a 0
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是不可逆的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
1.偏向锁:
一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,不需要再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁(因为synchronized是重入锁)。但是有多个线程竞争就不一样了,因为申请锁的线程都是不同的,所以偏向锁就不合适, 就要升级为轻量级锁。
2.轻量级锁:
倘若偏向锁失败,JVM并不会立即升级为重量级锁,而是先将他升级为轻量级锁,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,就是两三个线程不是竞争很激烈,一会是线程A锁住执行,线程A执行完后线程B再去锁住执行。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,竞争比较激烈了,就会导致轻量级锁膨胀为重量级锁。
3.自旋锁(轻量级锁升级为重量级锁时的一种优化):
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
4.重量级锁:
在Java中被抽象为监视器锁(monitor)。是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁, 互斥锁(重量级锁)也称为阻塞同步、悲观锁.
为什么重量级线程开销很大的?
当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
锁的粗化
每个代码块都被synchronized包裹,而且锁的都是同一个对象,所以jvm做了优化相当于在整个外面加一个synchronized。
int a, b, c;
synchronized (object) {
a = 1;
}
synchronized (object) {
b = 2;
}
synchronized (object) {
c = a + b;
System.out.println("c = " + c);
}
会被优化成如下:
synchronized (object) {
a = 1;
b = 2;
c = a + b;
System.out.println("c = " + c);
}
锁的消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,比如我有一个对象创建在方法里面,由于每个线程栈都是独立的空间,所以里面创建的局部变量或对象不会被其他线程所访问到,不会出现线程竞争的情况,因此会消除该变量或对象上的锁,可以参考我上面的JVM图,比如我那个Chen对象就是在Main线程中的method2方法中被创建的,这个Chen对象只会被当前这个Main线程所访问到,因此就算我在method2方法中给Chen对象加锁,也可能会被优化掉,JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。
锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除。
逃逸分析
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
是不是所有的对象和数组都会在堆内存分配空间?
不一定
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis : 表示开启逃逸分析 -XX:-DoEscapeAnalysis : 表示关闭逃逸分析。从jdk 1.7开始已经默认开启逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis
案例:
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
method();
}
while(true){
}
}
private static void method() {
Xuan xuan = new Xuan();
}
static class Xuan {
private int a;
}
未开启逃逸分析产生的对象数:
开启了逃逸分析后产生的对象数,明显有减少:
在不开启逃逸分析的情况下,会在堆中创建100000个Xuan对象,若开启了逃逸分析,在堆中创建的对象会小于100000,因为Xuan对象里面是一个基本数据类型,所以会在线程栈中开辟一点空间来放这个int变量,从而减少在堆区创建对象。而且线程栈中的对象随着线程执行完毕而被销毁删除,从而也更少的使用堆的空间,减少了gc的次数。
欢迎大家前来吐槽。