java多线程------synchronized
文章目录
- 一、概念
- 二、用法
- 1. 修饰代码块
- 2. 修饰方法
- 3. 修饰静态方法
- 三、锁状态
- 1. 无锁
- 2. 偏向锁
- 偏向锁加锁流程
- 偏向锁的撤销
- 3. 轻量级锁
- 轻量级锁的加锁流程
- 轻量级锁的撤销
- 轻量级锁膨胀
- 自旋锁
- 轻量级锁的缺点
- 4. 重量级锁
- 四、对象结构
- 对象头
- Mark Word的结构信息
- 实例数据
- 对齐填充
- 四、偏向锁、轻量级锁与重量级锁的对比
一、概念
在多线程中,代码需要同步我们需要用到锁,java提供了两种锁:**内置锁(synchronized)和显式锁(ReentrantLock)**两种同步方式。这里我们先介绍内置锁synchronized。
Java内置锁:每个Java对象都隐含有一把锁,称为Java内置锁(或者对象锁、隐式锁,)。Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须等待或者阻塞,直到线程A释放这个锁,如果线程A不释放这个锁,那么线程B将永远等待下去。
如何使用这些内置锁?
synchronized: synchronized 是java中的关键字,其作用是和一个内置锁组合,去对某个代码块或方法上锁。
public synchronized void add() {number++;}
二、用法
1. 修饰代码块
public void add() {synchronized (this) {number++;}}
字节码文件,两个jvm指令包裹:monitorenter
和monitorexit
拓展命令: javac xxxx.java
javap -verbose xxxx.class
2. 修饰方法
被synchronized修饰的方法称为 同步方法
,关键字synchronized的位置处于同步方法的返回类型之前。
示例:
public synchronized void add() {number++;}
字节码文件,通过方法的访问标志ACC_SYNCHRONIZED
实现同步。JVM 在调用该方法时隐式获取和释放锁
(锁对象是 this
)
3. 修饰静态方法
在Java世界里一切皆对象。Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。JVM将一个类加载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说其Class对象是唯一的。
Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因此不能显式地声明一个Class对象。
所有的类都是在第一次使用时被动态加载到JVM中的(懒加载),其各个类都是在必需时才加载的。这一点与许多传统语言(如C++)都不同,JVM为动态加载机制配套了一个判定一个类是否已经被加载的检查动作,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器就会根据类的全限定名查找.class文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象。
普通的synchronized实例方法,其同步锁是当前对象this的监视锁。如果某个synchronized方法是static(静态)方法,而不是普通的对象实例方法,其同步锁又是什么呢?
public static synchronized void add() {number++;}
字节码文件,通过方法的访问标志ACC_SYNCHRONIZED
实现同步。JVM 隐式锁定类的 Class
对象。其字节码没有显式锁指令,但效果等同于显式使用 synchronized (ClassName.class)
大家都知道,静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用(也叫指针、句柄)的。所以,修饰static方法的synchronized关键字就没有办法获
得Object实例的this对象的监视锁。
实际上,使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。
为了以示区分,这里将Object对象的监视锁叫作对象锁,将Class对象的监视锁叫作类锁。当synchronized关键字修饰static方法时,同步锁为类锁;当synchronized关键字修饰普通的成员方法(非静态方法)时,同步锁为对象锁。由于类的对象实例可以有很多,但是每个类只有一个Class实例,因此使用类锁作为synchronized的同步锁时会造成同一个JVM内的所有线程只能互斥地进入临界区段。
所以,使用synchronized关键字修饰static方法是非常粗粒度的同步机制。
通过synchronized关键字所抢占的同步锁什么时候释放呢?
一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。
三、锁状态
java1.6之前synchronized是重量级锁,重量级锁会造成CPU在系统的用户态
和核心态
之间频繁切换,代价非常高,效率非常低。为了解决这一问题,就引入了内置锁的4种状态。
Java内置锁的状态总共分为4种,级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。这4种状态随着竞争的情况逐渐升级,并且是不可逆的,即不可降级。(其是在JDK1.6之后优化添加的状态,之前只是重量级锁。)
1. 无锁
Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01。
2. 偏向锁
版本问题:jdk1.6~jdk15,偏向锁都是默认开启的,从jdk15开始,偏向锁是默认禁用的。所以,1.6到15,可以使用vm参数 XX:-UseBiasedLocking
来禁用,15之后可以使用XX:+UseBiasedLocking
来开启。
现在回到jdk1.8。
偏向锁加锁流程
-
jvm启动后
4s之前
创建的对象的状态都是无锁状态
也可以说是不可偏向状态
,Mark Word锁状态为001
-
延迟4s之
后创建的且没有被synchronized包裹的对象状态为可偏向状态
,即锁状态为101
,但没有指定偏向的线程id
,所以说是可偏向状态,也就是说,1.8版本在jvm启动4s之后创建的对象,都是可偏向状态。可以使用-XX:BiasedLockingStartupDelay=0
禁用偏向锁延迟。
-
之后被synchronized包裹之后,当第一个线程访问时,其Mark Word 就会把该线程对应的操作系统的线程id 写入 ,此时改为才正式处于
偏向锁
,即有线程id
和锁状态为101
,之后此线程访问只要判断ThreadId是否是自己即可。
-
当有第二个线程去获取锁时,偏向锁会撤销,然后会膨胀为
轻量级锁
,锁状态为00
,前62位改位存储线程栈帧中的琐记录指针 Local Record
。当第二个线程释放锁后
,该锁则成为无锁或不可偏向状态001
所以说,偏向锁是针对 单线程重复访问同步块的场景
,也就是说一段同步代码
一直被同一个线程
所访问,那么该对象就处于偏向锁的状态。
偏向锁的加锁过程中也会使用到cas,只不过是在第一个线程获得此锁时内置锁的线程ID为空,使用CAS交换,新线程只需判断内置锁对象的Mark Word中的线程ID是不是自己的ID,如果是就直接使用这个锁,而不使用CAS交换,新线程将自己的线程ID交换到内置锁的Mark Word中,如果交换成功,就加锁成功。
偏向锁的撤销
- 其他线程使用锁对象
也就是存在竞争,上面说了 - 调用锁对象的hashcode()方法
说明: 此示例VM参数已经设置了-XX:BiasedLockingStartupDelay=0
,不延迟4s启动偏向锁,也即是说若没有调用Dog的hashCode()方法,Dog对象直接就是可以是可偏向状态,所以没有睡眠4s,可直接用来验证hashCode问题。
原因:
因为如图
因为Mark Word 大小就只有64位
,如果是处于偏向锁状态,线程id就需要占用54位
,已经没有多余
空间存储31位
的hashCode值,所以说只能撤销或禁用该偏向锁,使Mark Word腾出空间去存储hashCode。
误区:之前里面Mark Word 表格以为5行是同时存在的,由上面解释现在可知,Mark Word 64位,每次只能存在其一行,其一行总共占据64位。所以hashCode 和偏向锁不能共存!!!。
为什么轻量级锁和重量级锁不会因为hashCode导致禁用或撤销
因为轻量级锁的hashCode 已经通过cas操作复制到线程栈帧的Local Record中,重量级锁被复制到操作系统的Moiniter 对象中,解锁后会把其还原回来。
3. 轻量级锁
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现
的重量级锁。
轻量级锁的加锁流程
在抢锁线程进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录
(Lock Record),用于存储对象目前Mark Word的拷贝
,然后抢锁线程将使用CAS自旋操作
,尝试将内置锁对象头的Mark Word的ptr_to_lock_record
(锁记录指针)更新为抢锁线程栈帧中锁记录的地址
,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后JVM将Mark Word中的lock标记位改为00(轻量级锁标志),即表示该对象处于轻量级锁状态。抢锁成功之后,JVM会将Mark Word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word(可以理解为放错地方的Mark Word)字段中,再将抢锁线程中锁记录的owner指针指向锁对象。
如图所示。
没有锁
的线程栈帧与对象
有轻量级锁
的线程栈帧与对象
也就是说: 把Mark Word 中存储的 hashcode 等信息 复制拷贝 到当前线程的Local Record 中,把当前线程的琐记录地址
存入Mark Word的 ptr_to_local_record 中。互相交换信息
。
锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的Mark Word的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用.
轻量级锁的撤销
- 持有锁的线程执行完毕释放锁
- 锁竞争加剧
轻量级锁膨胀
**其膨胀的触发条件为:**CAS失败次数、自旋时间或JVM动态判断。
比如:
线程A获取轻量级锁,执行同步代码块。线程B和线程C尝试获取同一锁,进入自旋等待。
线程A长时间未释放锁,线程B和线程C的自旋次数超过阈值。JVM检测到竞争激烈,决定将锁膨胀为重量级锁。
自旋锁
自旋锁分为普通自旋锁
和自适应自旋锁
。JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用-
XX:+UseSpinning选项手工开启。JDK 1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制。
**自适应自旋锁:**所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
轻量级锁的缺点
轻量级锁的问题在哪里呢?虽然大部分临界区代码的执行时间都是很短的,但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间,其他线程都在原地自旋等待,会空消耗
CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。
4. 重量级锁
重量级锁之所以重是因为其是使用了Linux内核态下的互斥锁,其会导致CPU在用户态和核心态之间频繁切换,所以开销很大。
在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。
在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的。ObjectMonitor的WaitSet、Cxq、EntryList这三个队列存放抢夺重量级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线程。
四、对象结构
java对象由三部分组成:对象头、实例数据、对齐填充
对象头
对象头包括三个字段,第一个字段叫作Mark Word
(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态等信息。
第二个字段叫作Class Pointer
(类对象指针),用于存放方法区Class对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第三个字段叫作Array Length
(数组长度)。如果对象是一个Java数组,那么此字段必须有,用于记录数组长度的数据;如果对象
不是一个Java数组,那么此字段不存在,所以这是一个可选字段。
Mark Word的结构信息
Java内置锁涉及很多重要信息,这些都存放在对象结构中,并且存放于对象头的Mark Word字段中。Mark Word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark Word为32位,64位JVM为64位。
Mark Word与Java内置锁的状态强相关。
- lock锁标志位:占两个二进制位,01、00、10,代表当前锁的状态,是哪种锁
- biased_lock是否偏向锁:占用1个二进制位,0是没有启动偏向锁,1是启用了偏向锁。
- age: 4位的Java对象分代年龄。在GC中,对象在Survivor区复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,因此最大值为15,这就是XX:MaxTenuringThreshold选项最大值为15的原因。
- identity_hashcode: 31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法或者System.identityHashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器)中。
- thread: 54位的线程ID值为持有偏向锁的线程ID。
- epoch:偏向时间戳。
- ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针
- ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。
实例数据
包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。
对齐填充
对齐字节也叫作填充对齐,其作用是用来保证Java对象所占内存字节数为8的倍数
,HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍。对象头本身是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。
四、偏向锁、轻量级锁与重量级锁的对比
总结一下synchronized的执行过程,大致如下:
- 线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
- 在内置锁对象确认为可偏向状态之后,JVM检查MarkWord中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
- 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
- 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
- JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
- 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁。如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级
锁
对比:
锁类型 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比,仅存在纳秒级别的差距 | 如果线程之间存在竞争,会带来额外的锁撤销消耗 | 适用于只有一个线程访问的临界区场景 |
轻量级锁 | 竞争线程不会阻塞,提高了程序的响应速度 | 抢不到锁的竞争线程使用CAS自旋等待,会消耗CPU | 锁占用时间很短,吞吐量低的场景 |
重量级锁 | 线程竞争不适用自旋,不消耗CPU | 线程阻塞,响应时间缓慢 | 锁占用时间较长,吞吐量高的场景 |