文章目录
1. synchronized概念
1.1 介绍
synchronized不同于前面提到的ReentrantLock,其是一个Java语言提供的关键字,从某种层面来说,与CAS操作(native方法)类似。
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized之所以能实现线程同步,主要是因为其保证了修饰语句/值的三个特性:
- 原子性:synchronized保证语句块内操作是原子的
- 可见性:synchronized保证可见性(确保“在执行unlock之前,必须先把此变量同步回主内存”实现)
- 有序性:synchronized保证有序性(确保“一个变量在同一时刻只允许一条线程对其进行lock操作”)
重量级synchronized
另外,在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
1.2 实现原理
我们知道,Java都在都要经过编译并由JVM虚拟机执行,而JVM虚拟机负责管理Java程序的执行顺序和内存资源。当某代码块/方法/类被synchronized修饰时,虚拟机会检测到,并在编译码的特定位置插入控制指令。
synchronized作用于**「方法」或者「代码块」**,保证被修饰的代码在同一时间只能被一个线程访问。
当synchronized修饰代码块的时候,JVM采用**「monitorenter、monitorexit」**两个指令来实现同步。
而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
而monitorenter、monitorexit或者ACC_SYNCHRONIZED都是**「基于Monitor实现」**的,任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。
当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
synchronized修饰方法时,JVM采用**「ACC_SYNCHRONIZED」**标记符来实现同步
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
实际上,synchronized修饰方法与修饰变量的底层原理并没有区别,同样是争抢monitor对象,不同的是争抢的时机。
$ javap -verbose SynchronizedDemo.class
Classfile /E:/works_documents/codes/java/test/src/main/java/com/cly/Synchronize/test_10/SynchronizedDemo.class
Last modified 2020-11-19; size 702 bytes
MD5 checksum 55a329b6e41aa6de44e1a8a056f17ada
Compiled from "SynchronizedDemo.java"
public class com.cly.Synchronize.test_10.SynchronizedDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#25 // java/lang/Object."<init>":()V
#2 = Class #26 // java/lang/String
#3 = Methodref #2.#25 // java/lang/String."<init>":()V
#4 = Fieldref #8.#27 // com/cly/Synchronize/test_10/SynchronizedDemo.a:Ljava/lang/String;
#5 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#6 = String #30 // heeee
#7 = Methodref #31.#32 // java/io/PrintStream.println:(Ljava/lang/String;)V
#8 = Class #33 // Synchronize/test_10/SynchronizedDemo
#9 = Class #34 // java/lang/Object
#10 = Utf8 a
#11 = Utf8 Ljava/lang/String;
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 g
#17 = Utf8 StackMapTable
#18 = Class #33 // Synchronize/test_10/SynchronizedDemo
#19 = Class #34 // java/lang/Object
#20 = Class #35 // java/lang/Throwable
#21 = Utf8 main
#22 = Utf8 ([Ljava/lang/String;)V
#23 = Utf8 SourceFile
#24 = Utf8 SynchronizedDemo.java
#25 = NameAndType #12:#13 // "<init>":()V
#26 = Utf8 java/lang/String
#27 = NameAndType #10:#11 // a:Ljava/lang/String;
#28 = Class #36 // java/lang/System
#29 = NameAndType #37:#38 // out:Ljava/io/PrintStream;
#30 = Utf8 heeee
#31 = Class #39 // java/io/PrintStream
#32 = NameAndType #40:#41 // println:(Ljava/lang/String;)V
#33 = Utf8 com/cly/Synchronize/test_10/SynchronizedDemo
#34 = Utf8 java/lang/Object
#35 = Utf8 java/lang/Throwable
#36 = Utf8 java/lang/System
#37 = Utf8 out
#38 = Utf8 Ljava/io/PrintStream;
#39 = Utf8 java/io/PrintStream
#40 = Utf8 println
#41 = Utf8 (Ljava/lang/String;)V
{
public com.cly.Synchronize.test_10.SynchronizedDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class java/lang/String
8: dup
9: invokespecial #3 // Method java/lang/String."<init>":()V
12: putfield #4 // Field a:Ljava/lang/String;
15: return
LineNumberTable:
line 3: 0
line 8: 4
public void g();
descriptor: ()V
flags: ACC_PUBLIC // 修饰代码块不会在方法访问标志中设置
Code:
stack=2, locals=3, args_size=1
0: aload_0 // 从局部变量0中装载引用类型值入栈。
1: getfield #4 // 获取对象字段的值 String
4: dup // 复制栈顶一个字长的数据,将复制后的数据压栈。
5: astore_1 // 将栈顶引用类型值保存到局部变量1中
6: monitorenter // 监视器入口
7: getstatic #5 // 获取静态字段的值 #5
10: ldc #6 // 常量池中的常量值(int, float, string reference, object reference)入栈
12: invokevirtual #7 // 运行时方法绑定调用方法。
15: aload_1 // 从局部变量1中装载引用类型值入栈
16: monitorexit // 监视器出口,释放锁
17: goto 25 // 无条件跳转到指定位置
20: astore_2 // 将栈顶引用类型值保存到局部变量2中
21: aload_1 // 从局部变量1中装载引用类型值入栈
22: monitorexit // 发生异常,释放锁
23: aload_2 // 从局部变量1中装载引用类型值入栈
24: athrow // 抛出异常
25: return
Exception table:
from to target type
7 17 20 any
20 23 20 any
LineNumberTable:
line 11: 0
line 12: 7
line 13: 15
line 14: 25
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 20
locals = [ class com/cly/Synchronize/test_10/SynchronizedDemo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 18: 0
}
1.4 Monitor对象
那么对象锁在内存中是怎样的呢?接下来就来看一下对象锁的实现细节。对象锁的状态是记录在对象头中的Mark word区域中。在不同的锁状态下,Mark word会存储不同的信息,这也是为了节约内存常用的设计。当锁状态为重量级锁(锁标识位=10)时,Mark word中会记录指向Monitor对象的指针,这个Monitor对象也称为管程或监视器锁。
在HotSpot虚拟机中,Monitor是基于C++的ObjectMonitor类实现的,其主要成员包括:
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程 (阻塞队列)
- _EntryList:存放处于等待锁block状态的线程队列 (阻塞队列)
- _count:等待线程数加睡眠线程数的和
- _cxq: 多个线程争抢锁,会先存入这个单向链表 (就绪队列)
- _recursions: 记录重入次数,通过此计数器实现可充入
为什么说Synchronized是非公平锁
非公平锁主要体现在获取锁的行为上,Synchronized并非是按照申请锁的时间前后给等待的线程分配锁。每个锁被释放后,任何一个线程都有机会竞争到锁。这样做得有点,为了提高性能。缺点是可能产生线程饥饿的现象。
1.3 synchronized使用场景
一般synchronized关键字修饰可修饰一下场景,
- 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
- 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
- 修饰代码块 :指定加锁对象,对给定对象/类加锁。
// 同步代码块,
synchronized(Account.class){
}
// 同步方法
public synchronized void deposit(double amt){
}
// 同步变量对象
synchronized (this){
}
注意:
- 修饰this,表示锁住当前对象
- 修饰A.class,表示锁住当前类的静态方法
- 修饰方法,根据静态/非静态划分
1.4 JVM对锁机制的优化
1.4.1 在Java SE 1.6时的锁机制
在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为 「重量级锁」 。此时对象只有两种锁状态:无锁、重量级锁,而重量级锁涉及到用户态到内核态的切换(要么执行要么阻塞),因此效率特别低。
因此为了提高效率,从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略。
1.4.2 偏向锁、轻量级锁
研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程或少数几个进程多次获得,此时线程根本无需等待或者等待时间极短,也就不需要线程上下文切换(阻塞)。
-
偏向锁:在无竞争的情况下,把整个同步都消除掉,CAS操作都不做,当一个线程访问对象并获取锁时,会在对象头里存储锁偏向的这个线程的ID,以后该线程再访问该对象时只需判断对象头的Mark Word里是否有这个线程的ID,如果有就不需要进行CAS操作
-
轻量级锁:当线程竞争更激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程就会释放锁,但是当自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁。
-
重量级锁:通过持有Monitor实现,未持有Monitor的对象会被挂机,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。
轻量级锁CAS的过程
轻量级CAS将MarkWord中的部分字节Cas更新指向线程栈中的lock record。Lock Record:JVM检测到当前对象是无锁状态,会在当前线程创建一个名为lockRecord的空间,用户Copy markWord中的数据,如果更新成功,则轻量级获取成功,标记为轻量级。
1.4.3 锁实验
不加锁:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class Test_1{
public static void main(String[] args) {
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) 28 0f cf 16 (00101000 00001111 11001111 00010110) (382668584)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
markword的锁状态为01,表示未加锁
标志位 | 锁状态 |
---|---|
001 | 无锁 |
101 | 偏向锁 |
x00 | 轻量级锁 |
x10 | 重量级锁 |
x11 | GC标记信息 |
有个奇怪现象,ReentrantLock锁之后也会出现对象头的锁标记变化。
加锁:
public class Test_1{
public static void main(String[] args) {
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) 18 f3 9a 02 (00011000 11110011 10011010 00000010) (43709208)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f d4 16 (00101000 00001111 11010100 00010110) (382996264)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
markword的锁状态为000,表示未加锁,为什么呢?
休眠5s后加锁:
public class Test_1{
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
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 f8 52 02 (00000101 11111000 01010010 00000010) (38991877)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f d8 16 (00101000 00001111 11011000 00010110) (383258408)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
markword的锁状态为101,表示偏向锁
因为JDK1.8默认在4s后开启偏向锁,为什么呢?
有人说这是一个妥协,如果说多个线程竞争某对象时,那么会立刻进入轻量级锁,所以为了避免直接开启偏向锁后又进入轻量级锁的开销,JDK1.8选择在4s后开启。
1.4.4 JVM提出的其他优化
自适应自旋:
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
- 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
自适应自旋解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。
锁粗化:
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。
for(int i=0;i<size;i++){
synchronized(lock){ // 每一次for循环都要经过申请锁、释放锁
}
}
锁消除:
锁消除是发生在编译器级别的一种锁优化方式。
有时候我们写的代码完全不需要加锁,却执行了加锁操作。
@Override
public synchronized int append() {
int a = 20; // 方法内部是内部操作私有变量,是线程安全的
return a;
}
2. 控制方法
2.1 sleep()和wait()方法的区别
- 两者最主要的区别在于:
sleep()
方法没有释放锁只释放了CPU资源,而wait()
方法释放了锁和CPU资源 - 两者都可以暂停线程的执行。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。
synchronized实现交替打印
class print implements Runnable{
private static int count = 1;
private final static Object lock = new Object();
@Override
public void run() {
while(count <= 10){
synchronized (lock){
System.out.println(Thread.currentThread().getName() + ":" + count++);
lock.notify();
if(count <= 10){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
public class Test_2 {
public static void main(String[] args) {
new Thread(new print(),"线程1").start();
new Thread(new print(),"线程2").start();
}
}