本篇内容包括:Synchronized 关键字简介、synchronized 的修饰对象、对象的内存布局(64位)、Synchronized 锁升级过程等内容。
一、Synchronized 关键字简介
Synchronize 翻译成中文:同步,使同步。synchronized:已同步。synchronized 能够保证同一时刻最多只有一个线程执行该段代码,以达到并发安全的效果。也就是说 Synchronized 在某个线程将资源锁住了之后,其他线程只有在当前线程使用完成后,才可以接着使用。
synchronized 是 Java 中解决并发问题的一种最常用也最简单的一种方法。synchronized 的作用主要有三个:
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见(其实是通过 Java 内存模型中的 “对一个变量 unlock 操作之前,必须要同步到主内存中;如果对一个变量进行 lock 操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中 load 操作或 assign 操作初始化变量值” 来保证的)
- 有序性:有效解决重排序问题,即 “一个 unlock 操作先行发生(happen-before)于后面对同一个锁的 lock 操作”。
二、synchronized 的修饰对象
synchronized 可以修饰普通方法,静态方法和代码块。 当synchronized修饰一个方法或者一个代码块的时候,它能够保证在同一时刻最多只有一个线程执行该段代码。
1、synchronized 修饰静态方法,当前类的 Class 对象(类)
我们知道,静态方法实际不属于类的任何一个对象实例,它是直属于“类”的。如果在静态方法上加上 Synchronized 关键字,那么它锁住的就是这个类
2、synchronized 修饰实例方法,锁是当前实例对象(对象)
普通方法并不是这个类独有的:创建多少个对象实例,这个方法就会有多少个。那么当 Synchronized 关键字修饰类的普通方法时,它锁住的就是这个类的对象实例
3、synchronized 修饰代码块,锁是 Synchonized 括号里配置的对象
同步代码块锁主要是对代码块进行加锁,此时同一时刻只能有一个线程获取到该资源,要注意每一把锁只负责当前的代码块,其他的代码块不管
三、对象的内存布局(64位)
因为 Synchronized 都是对对象进行加锁,那我们在了解它的底层实现原理之前,应该了解一下Java对象在内存中的布局,这样比较有利于我们理解,对于一个普通对象来说,它分为四个部分:
- mark-word:是整个对象内存布局的重中之重,因为它里面存储了很多非常重要的信息,它共占8个字节,存储了对象的hashcode**、锁信息、**分代年龄和GC标志信息,它与class pointer共同称之为对象头;
- class pointer:存储的是该对象的class文件地址,换句话说,就是可以通过 class pointer 知道,这是哪个类的对象;
- instance data:存放的一般是类中的变量数;
- padding data:不一定会用到,它最主要的作用就是保证整个对象所占的字节可以被8字节整除,这样做的目的其实是为了提高读取的效率。
四、Synchronized 锁升级过程
synchronized锁有四种状态,无锁,偏向锁,轻量级锁,重量级锁。锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态
jdk1.6之前都是重量级锁,大多数时候是不存在锁竞争的,如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入锁升级。
1、偏向锁
(线程1)获取锁对象时,会在 Java 对象头和栈帧中记录偏向的锁的 ThreadID,下一次,线程获取该锁时会比较 ThreadID 是否一致:
- 一致(线程1) --> 直接进入而无需使用 CAS 来加锁、解锁;
- 不一致(线程2) --> 检查对象的 ThreadID 线程是否还存活:
- 如果存活 --> 代表该对象被多个线程竞争,于是升级成轻量级锁;
- 否则不存活 --> 将锁重置为无锁状态,锁头重新标记线程为新的 ThreadID(抢占偏向锁失败的线程会触发锁膨胀至轻量级锁)。
如果线程1和线程2的执行时间刚好错开,那么锁只会在偏向锁之间切换而不会升级为轻量级锁,在使用Synchronized的情况下避开了获取锁的成本,所以效率和无锁状态非常接近
2、轻量级锁
对象被多个线程竞争(或关闭偏向锁功能)时,锁由偏向锁升级为轻量级锁,其他线程会通过 CAS + 自旋 的形式尝试获取锁(JDK 1.7 之前是普通自旋,会设定一个最大的自旋次数,默认是 10 次,超过这个阈值就停止自旋。JDK 1.7 之后,引入了适应性自旋。简单来说就是:这次自旋获取到锁了,自旋的次数就会增加;这次自旋没拿到锁,自旋的次数就会减少。)
- 膨胀成轻量级锁以后,如果后续的线程是在持有锁的线程执行结束后来抢锁,拿到的依然是轻量级锁。因为释放轻量级锁会恢复成无锁。
- 膨胀成轻量级锁以后,如果后续的线程是在持有锁的线程执行结束前来抢锁,就会触发膨胀成重量级。
轻量级锁获取过程:
在代码进入同步块的时候,如果同步对象锁状态为无锁状态,轻量级锁会构造一个Lock Record锁记录,用于存储锁对象目前的 Mark-Word 的拷贝
Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word
),表示该锁被这个线程占用。
拷贝成功后,虚拟机将使用 CAS 尝试将对象头的 MarkWord 的 Lock-Word(锁记录指针) 指向当前线程 Lock Record 的起始地址,并将 Lock Record 的 owner 指向对象的 Mark-Word:
- 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark-Word 的锁标志位状态设置为
00
,表示此对象处于轻量级锁定状态。 - 如果这个更新动作失败了,虚拟机首先会检查对象的 Lock-Word(锁记录指针) 是否指向当前线程的 Lock Record,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁,锁升级为重量级。
3、重量级锁
当线程的自旋后依然没获取到锁或者判定多个线程竞争锁时,为避免CPU无端耗费,锁由轻量级锁升级为重量级锁。
升级为重量级锁时,锁标志的状态值变为 10
,此时 MarkWord 的 Lock-Word 指向重量级锁的指针,获取锁的同时会阻塞其他正在竞争该锁的线程,依赖对象内部的监视器(monitor)实现,monitor 又依赖操作系统底层,需要从用户态切换到内核态,成本非常高。
Synchronized 中对 monitor 锁的实现有用到两个指令: monitorenter 和 monitorexit (可通过 javap -verbose XXX.class 反汇编查看)。 Synchronized 在 JVM 里的实现都是 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现,可以把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0。 monitorenter:执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:
- 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。
- 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
- 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。
monitorexit:monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权 再底层的,monitor又依赖操作系统的 MutexLock(互斥锁)来实现的,所以重量级锁也被称为互斥锁。(Mutex 在 Windows 和 Linux中的实现有着显著的区别)