synchronized
# 设计同步器的意义
# 如何解决线程并发安全问题?
- 序列化访问临界资源::即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
- 多线程共享,可变
# 多线程执行的过程是不可控的?
所以需要采用同步机制来协同对对象可变状态的访问!
# synchronized的使用
synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
# 加锁方式
synchronized 实际是用对象锁保证了临界区内代码的原子性
- 实例方法
- 静态方法
- 代码块
# sychronized的唤醒策略 (opens new window)
- 如果没有其它线程等锁,结束;
- 优先唤醒 EntryList 链表头部的线程;
- 如果 EntryList 是空的,把 cxq 链表赋值给 EntryList 链表,再唤醒 EntryList 头部的线程。
而由于入队的时候 cxq 链表是头插的,所以 synchronized 默认的唤醒策略是,最后阻塞等锁的最先唤醒。
# 底层原理
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换(简称:上下文切换),对性能有较大影响。所以它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平有时更快。
# 字节码
Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和 MonitorExit指令来实现。
# Monitor(管程/监视器)
Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。
# 什么是monitor
- 一个同步工具、一种同步机制、描述为一个对象
- 每一个Java对象本身就带一把 看不见的锁,它叫做内部锁或者Monitor锁
- Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式 获取锁的
# Monitor监视器锁
Synchronized的语义底层是通过一个monitor的对象来 完成,其实wait/notify等方法也依赖于monitor对象。这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则 会抛出java.lang.IllegalMonitorStateException的异常的原因
# Java内置管程synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。就是wait/notify/notifyAll。这样做简化了使用门槛。
# Monitor机制在Java中的实现 (opens new window)
具体得看jvm源码了。。这篇 https://www.cnblogs.com/qingshan-tang/p/12698705.html 博客写的还不错。
# wait()的正确使用姿势
对于MESA管程来说,有一个编程范式: 再wait方法中的demo就是这样写的.
* synchronized (obj) {
* while (<condition does not hold>)
* obj.wait();
* ... // Perform action appropriate to condition
* }
2
3
4
5
为什么要这样写呢?? 唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。
# notify()和notifyAll()分别何时使用
满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
对象的内存布局
对象头
hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度等等
Java对象头一般占有2个机器码,如果对象是数组类型,则需要3个机器码
Mark Word
- 存储对象自身的运行时数据
- Mark Word会随着程序的运行发生变化,以复用自己的存储空间
- 哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
哪些信息会被指针压缩
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
- 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
# 对象的内存布局
# 对象头
hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度
Java对象头一般占有2个机器码,如果对象是数组类型,则需要3个机器码
# Mark Word
- 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
- Mark Word会随着程序的运行发生变化,以复用自己的存储空间
# Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。
哪些信息会被指针压缩:
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
- 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
# 数组长度
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节
# 实例数据
存放类的属性数据信息,包括父类的属性信息;
# 对齐填充
由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
# 对象头分析工具
运行时对象头锁状态分析工具JOL
# 锁的膨胀升级
# 膨胀升级过程
- 无锁状态、偏向锁、轻量级锁和重量级锁。只能从低到高升级,不会锁降级
- JDK 1.6 中默认是开启偏向锁和轻量级锁的
# 无锁状态
# 偏向锁
线程获得了锁,那么锁就进入偏向模式,当同一线程再次请求锁时,无需再做任何同步操作,省去大量的锁申请操作,从而也就提高性能
# 偏向锁延迟偏向
偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
# 轻量级锁
- 转化时机:::修改偏向锁线程ID失败后,撤销偏向锁标识,改成轻量级锁的结构
- 应用场景:::: 对绝大部分的锁,在整个同步周期内都不存在竞争,也就是几个线程交替执行同步块的场合。
# 重量级锁(自旋锁)
- 适应性自旋
- Mutex互斥量 (涉及上下文切换)
# 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
- Java 7 之后不能控制是否开启自旋功能
注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)
# 锁粗化
假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。 比如: 每次调用 StringBuffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
# 锁消除
JIT编译时,去除不可能存在共享资源竞争的锁,节省毫无意义的请求锁时间
如: StringBuffer的append是一个同步方法,但是在add方法中StringBuffer属于一个局部变量,并且不会被其他线程所使用,JVM会自动将其锁消除
锁消除的依据是逃逸分析的数据支持
- java必须运行在server模式 -server
- 同时必须开启逃逸分析 -XX:+DoEscapeAnalysis
- 开启锁消除 -XX:+EliminateLocks
# 逃逸分析
从jdk 1.7开始已经默认开启逃逸分析 优化
- 同步省略
- 将堆分配转化为栈分配
- 分离对象或标量替换
# synchronized和lock的选择
参考 [并发编程]synchronized与lock的性能比较 (opens new window)
性能:在高并发的情况下都是在一个数量集的,较高和较低并发下,synchronized都比Lock来的快,基本是两倍的关系。
我们写同步的时候,优先考虑synchronized,如果有特殊需要,再进一步优化,可以考虑使用lock。
这里和AQS原理相关: LockSupport.park 也是操作系统的mutex和重量级锁一样了。 所以低并发下,synchronized是偏向锁和轻量级锁。 超高并发下: AQS的cas空循环再
# 参考资料
Unsafe.park和Unsafe.unpark的底层实现原理 (opens new window)
https://cloud.tencent.com/developer/article/1460321 在Linux系统下,是用的Posix线程库pthread中的mutex(互斥量),condition(条件变量)来实现的。