1、使用方法
修饰实例方法,对当前实例对象this加锁
public class Synchronized {
public synchronized void test(){
}
}
//修饰代码块,指定一个加锁的对象,给对象加锁
public class Synchronized {
public void test(){
synchronized(this){
}
}
}
修饰静态方法,对当前类的Class对象加锁,当JVM加载类文件时,它会创建类java.lang.Class的实例。当锁定一个类时,实际上锁定了那个类的类对象。
public class Synchronized {
public synchronized static void test(){
}
}
public class Synchronized {
public static void test(){
synchronized(Synchronized.class){
}
}
}
2、线程和共享数据
Java虚拟机的运行时数据区中的堆和方法区是所有线程共享的区域,如果多个线程需要同时使用(读写)共享的对象或类变量,则必须要正确协调它们对数据的访问。否则,程序将具有不可预测的行为。为了协调多个线程之间对共享数据的访问,Java虚拟机将锁与每个对象或类关联起来。
JVM的内存结构主要包含以下几个重要的区域:栈、堆、方法区等。
栈 | 1、栈内存是线程独享的,其中包括局部变量、线程调用的每个方法的参数和返回值。 2、其他线程无法读取到该栈内存块中的数据。栈中的数据仅限于基本类型和对象引用。所以,在JVM中,栈上是无法保存真实的对象的,只能保存对象的引用。真正的对象要保存在堆中。 |
堆 | 1、堆内存是所有线程共享的。堆中只包含对象,没有其他东西。所以,堆上也无法保存基本类型和对象引用。 2、堆和栈分工明确。但是,对象的引用其实也是对象的一部分。这里值得一提的是,数组是保存在堆上面的,即使是基本类型的数据,也是保存在堆中的。因为在Java中,数组是对象。 |
方法区 | 除了栈和堆,还有一部分数据可能保存在JVM中的方法区中,比如类的静态变量。方法区和栈类似,其中只包含基本类型和对象的引用。和栈不同的是,方法区中的静态变量可以被所有线程访问到。 |
在 JVM 中,对象在内存中分为三块区域:
对象头 | Mark Word(标记字段) 默认存储对象的HashCode、分代年龄、锁标志位、线程持有的锁、偏向线程ID等数据。在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。 |
Klass Word(类型指针)指向对应的 Class 对象,虚拟机通过这个指针来确定这个对象是哪个类的实例。 | |
实例数据 | 这部分主要是存放类的数据信息,父类的信息。 |
对齐填充 | 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。 |
对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。
3、Monitor 原理
Monitor 被翻译为监视器或者说管程,在Java中可以把它看作为一个同步工具,相当于操作系统中的互斥量。
1、每个 java 对象都可以关联一个 Monitor ,如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word 中就被设置为指向 Monitor 对象(是由C++实现)的指针。
2、不加synchronized的对象不会关联Monitor
1、 | 刚开始时 Monitor 中的 Owner 为 null。 |
2、 | 当线程执行synchronized(obj){} 代码时就会将 Monitor的所有者Owner设置为当前线程,这个时候上锁成功。Monitor 中同一时刻只能有一个 Owner。 |
3、 | 如果拥有锁的线程还未释放锁,此时其它线程也来执行synchronized(obj){} 代码,这些线程就会进入 EntryList(阻塞队列)中变成BLOCKED(阻塞) 状态。 |
4、 | 拥有锁的线程执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的。 |
5、 | 图中 WaitSet中存储的是之前获得过锁,但条件不满足进入 WAITING 状态的线程,当被唤醒之后,同样会进入EntryList队列,同其它线程一同竞争。 |
所以归根究底,还是对monitor对象的争夺。
Monitor是依赖于底层操作系统的mutex lock(互斥锁)来实现互斥的,这样就存在用户态与内核态之间的切换,所以会增加性能开销。
4、JVM对synchronized的优化
Linux系统的体系结构大家大学应该都接触过了,分为用户空间(应用程序的活动空间)和内核。
我们所有的程序都在用户空间运行,进入用户运行状态也就是(用户态),但是很多操作可能涉及内核运行,比如I/O,我们就会进入内核运行状态(内核态)。
1.6之前是重量级锁,没错,但是他重量的本质,是ObjectMonitor调用的过程,以及Linux内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。
从Mark Word中的标志位,我们可以看出优化后的synchronized同步锁一共有四种状态:无锁、偏向锁、轻量级所、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
Mark Word存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 biased_lock : 0 | 01 | 无锁 |
偏向线程ID、偏向时间戳、对象分代年龄 biased_lock : 1 | 01 | 偏向锁 |
指向锁记录的指针 | 00 | 轻量级锁 |
指向重量级锁的指针 | 10 | 重量级锁定 |
空,不需要记录信息 | 11 | GC标记,准备垃圾回收,CMS回收器用到 |
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
偏向锁
偏向锁是为了在无多线程竞争情况下尽量减少不必要的轻量级锁执行路径点。轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作;而偏向锁只需在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能开销必须小于节省下来的CAS原子指令的性能消耗) 。
轻量级锁在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
1 | 检查Mark Word 是否为可偏向锁的状态,偏向标志位为1即表示支持可偏向锁,否则为0表示不支持可偏向锁。 |
2 | 如果是可偏向锁,则检查 |
3 | 如果检查到 |
4 | 竞争失败表示有竞争,当到达全局安全点(safepoint)时获取偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行同步代码。 |
轻量级锁
轻量级锁的本意是在没有多线程竞争的前提下,减少重量级锁使用产生的性能消耗。
适用场景:线程交替执行同步块的情况。当存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
具体的操作步骤:
1 |
每个线程都的栈帧都会包含一个锁记录(Lock Record)的结构,内部可以存储锁定对象的 Mark Word。
|
2 |
拷贝对象头中的Mark Word到当前线程的锁记录Lock Record中。
|
3 | 拷贝成功后,虚拟机使用CAS操作来尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record中的owner指针指向对象的mark word。
若更新成功,线程拥有该对象的锁,将对象的锁标志位设为“00”,表示对象处于轻量级锁状态。 |
4 | 如果失败了,就会判断当前对象的Mark Word是否指向了当前线程栈帧的锁记录, 1、是则表示当前的线程已经持有了这个对象的锁 2、否则,说明多个线程竞争锁,轻量级锁升级为重量级锁,锁状态变为10,Mark Word存储指向重量级锁(互斥量)的指针,后面等待锁的线程进入阻塞状态。 |
重量级锁
1 |
当
Thread-1
进行轻量级加锁时,
Thread-0
已经对该对象加了轻量级锁
|
2 |
这时
Thread-1
加轻量级锁失败,进入锁膨胀流程
即为
Object
对象申请
Monitor
锁,让
Object 指向重量级锁地址,Monitor的owner执行Thread-0
然后自己进入
Monitor
的
EntryList BLOCKED
|
3 |
当
Thread-0
退出同步块解锁时,使用
cas
将
Mark Word
的值恢复给对象头。
这时会进入重量级解锁流程,即按照 Monitor
地址找到
Monitor
对象,设置
Owner
为
null
,唤醒
EntryList
中
BLOCKED
线程
|
5、其它锁优化
自旋优化
线程获取轻量级锁的过程中执行CAS操作失败时,通过自旋来获取重量级锁。
在重量级锁的使用中就使用了Monitor这个操作系统级别的锁对象,上面提到了操作系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?锁就进行了自旋优化。“自旋”就是当前没有获得锁的线程可以尝试进行循环等待(可以配置次数),直到目标达成。而不像普通的锁那样,如果获取不到锁就直接进入阻塞。如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
锁粗化
对同一个对象的多次加锁、解锁操作合并为一次。
对相同对象多次加锁,导致线程发生多次重入,可以使用锁粗化方式来优化。
StringBuffer.append()中为同步操作,使用synchronized来加锁,这里将多次加锁合并为一次。
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
锁消除
删除不必要的加锁操作,对线程安全的代码,去除锁。
根据代码逃逸技术,若判断一段代码中的数据不会逃逸出当前线程,认为这段代码是线程安全的,不必要加锁。例子中的对象锁o是没必要的,因为其是局部变量不会被共享,这个对象锁加与不加都不会影响最终的结果。因此JIT(java即时编译器)会将这块代码优化掉。
//会被优化的代码
public class Test {
static int x = 0;
public void a() {
x++;
}
public void b(){
Object o = new Object();
synchronized (o) {
x++;
}
}
}
public class Test {
Object o = new Object();
static int x = 0;
public void a() {
synchronized(o) {
x++;
}
}
public void b(){
synchronized(o) {
x++;
}
}
}