1. Synchronized简介及使用
1.1 简介
在Java中,synchronized 是一个关键字,用于实现多线程环境下的同步控制,确保线程安全性。它可以应用于方法、代码块或静态方法上,提供了对临界区(共享资源)的互斥访问,防止多个线程同时访问和修改共享数据时出现数据不一致或其他并发问题。
synchronized 是 Java 中实现线程安全的基本方式之一,但它可能会带来一些性能开销。Java 5 之后,另外的同步机制 java.util.concurrent (JUC)下的锁机制更为灵活,比如 ReentrantLock,提供了更多控制和功能。
1.2 特点
-
互斥性: synchronized 确保同一时间只有一个线程可以进入被 synchronized 保护的区域。
-
锁的获取与释放: 当线程进入 synchronized 区域时,它尝试获取对象的锁。当离开 synchronized 区域时,会释放锁。
-
递归性: Java 中的 synchronized 支持线程的递归锁,允许一个线程在持有锁的情况下再次获取该锁,而不会被自身所阻塞。
-
对象监视器: synchronized 使用对象的监视器(monitor)来确保线程同步。对于方法而言,是当前实例对象;对于静态方法,是当前类的 Class 对象。
1.3 使用
它可以应用于方法、代码块或静态方法上,提供了对临界区(共享资源)的互斥访问,防止多个线程同时访问和修改共享数据时出现数据不一致或其他并发问题。
使用方法:
- 在方法上使用 synchronized:可以用在方法声明前,修饰非静态的方法,将方法设置为同步方法。这种情况下,锁是当前对象实例。
public synchronized void someMethod() {
// 同步代码块
}
- 在代码块上使用 synchronized:也可以应用于代码块中,通过指定一个对象作为锁,来确保对某段代码的同步访问。
Object lock = new Object();
synchronized (lock) {
// 同步代码块
}
- 静态方法中使用 synchronized:也可以用在静态方法声明前,修饰静态方法,将静态方法设置为同步方法。这种情况下,锁是类级别的,作用于整个类的所有实例。
public static synchronized void someStaticMethod() {
// 静态同步方法
}
2. Synchronized原理1:底层实现
Synchronized的底层实现依赖于Monitor和与之关联的对象的对象头,所以要想了解Syncrronized必须先了解Monitor,还要了解对象的对象头信息,先来介绍一下对象的对象头:
2.1 对象头
在 Java 中,对象头(Object Header)是存储在每个对象实例中的一部分数据结构,用于管理对象的元数据信息。对象头位于对象实例数据之前,用于支持 Java 对象的各种特性和语言功能。
对象头通常包含以下信息:
- 哈希码(HashCode):
用于支持对象的哈希码,通常是对象的内存地址经过计算得到的值。
- 锁状态(Lock State):
用于存储对象的锁状态信息。在多线程环境下,这部分信息被用来实现 synchronized 关键字提供的锁机制,确保线程安全。
- 垃圾回收信息(Garbage Collection Information):
包括对象的标记状态、分代年龄、是否可回收等信息,用于支持 Java 的垃圾回收机制。
- 其他元数据信息:
可能还包括一些其他的元数据,比如指向类的元数据(class metadata)指针等。
对象头是 Java 对象的一部分,由 JVM 在运行时根据对象的类型和状态动态管理。对象头中存储的信息对于 Java 对象的生命周期、同步状态、哈希码计算以及垃圾回收等方面非常重要。
对象头中的具体结构和内容取决于具体的 JVM 实现和对象的状态,因此可能在不同的 JVM 实现中略有差异。对象头的管理对于 Java 的对象模型和内存管理是至关重要的。
对象头的结构:
其中Klass World是指向该对象所属类的引用,即指向方法区中的类对象。
数组对象相比普通对象对了一个array length 用来存储数组的长度。
对象头中Mark World的结构信息:
2.2 Monitor
2.2.1 Monitor简介
2.2.2 Monitor结构
Owner:指向持有锁的线程;
EntryList:指向一个阻塞队列,该队列存放因为获取不到该对象所进入阻塞状态的进程们;
MaitSet:指向等待状态的线程,即在Synchronized代码块中调用了该对象所得.wait()方法,那么当前线程会被放入这个WaitSet指向的集合中,当其中的某个对象被唤醒后,就进入EntryList,从而继续竞争锁资源
2.3 Synchornized的底层原理
2.3.1 Synchronized的原理如下:
2.3.2 举例说明:
当前线程2持有锁,具体来说:每一个被用来当做锁的对象,当某一个线程持有该对象锁时,其对象头中的Mark Word会被修改为重量级锁的类型,即下面的结构,其Mark Word的前30位指向操作系统提供弄得Monitor对象,而这个对象的hash code 分代年龄信息会保存到Monitor里面。
此时Monitor中的Owner会指向线程2,这时再有线程过来抢占锁,会进入Monitor的等待队列EntryList排队等待。
当线程2执行完临界区代码块,会恢复Mark Word的信息,即:
线程2释放锁资源,执行结束。
这时线程1抢占了锁,Monitor中的Owner就会指向线程1,此时,线程1就是锁的持有者,而线程3需继续等待,直到线程1释放了锁。
需要注意的是:线程2释放锁资源后,会唤醒处于阻塞队列上的线程,线程1和线程3并不是按照先来后到的原则去抢占锁的,因为Synchronized是非公平锁。
2.3.3 再从字节码角度理解一下执行逻辑:
2.3.4 总结
3. Synchronized原理2:锁优化
3.1 锁的概念
3.1.1 重量级锁
重量级锁是指 Java 中的重量级同步锁(使用Monitor实现的即是重量级锁),也称为传统的互斥同步。它是 synchronized 关键字所使用的一种锁状态,用于在多线程环境下保护共享资源,确保在同一时间只有一个线程能够进入同步代码块执行。
特点包括:
-
互斥性: 重量级锁是一种排他性锁,当一个线程获得锁并进入同步代码块时,其他线程必须等待持有锁的线程释放锁,才能继续执行同步代码块。
-
基于操作系统的阻塞: 当多个线程竞争同一个锁时,锁会升级为重量级锁。这时会涉及到操作系统层面的内核态的线程阻塞与唤醒。
-
性能开销: 重量级锁的性能开销相对较大。因为它涉及到线程的阻塞和唤醒,频繁的上下文切换和内核态和用户态的切换,会增加系统的开销。
-
适用范围: 适用于多线程竞争激烈的情况,或者同步块执行时间较长的情况,此时引入重量级锁能够确保线程安全。
重量级锁是在多线程环境中确保线程安全的传统锁机制,但在一些情况下由于性能开销比较大,可能会影响系统的并发性能。因此,Java 在锁升级的过程中会尽量避免将锁直接升级为重量级锁,而会尝试将锁保持在偏向锁或轻量级锁状态,以提高多线程环境下的性能表现。
3.1.2 轻量级锁
轻量级锁(Lightweight Locking)是 Java 中用于优化同步锁性能的一种锁状态。它是为了解决多线程环境下对同步锁竞争的性能问题而引入的优化方案。
- 特点包括:
-
自旋锁: 轻量级锁采用乐观锁的思想,在竞争不激烈的情况下,采用自旋锁避免线程进入阻塞状态。
-
CAS 操作: 使用 Compare and Swap(CAS)操作来避免多线程竞争。当只有一个线程持有锁,其他线程会使用 CAS 操作尝试获取锁,而不会直接进入阻塞状态。
-
标识对象: 轻量级锁通过在对象头中的 Mark Word 部分设置标志位,用于记录当前锁的状态。这个标志位包含了指向线程栈中锁记录(Lock Record)的指针。
-
升级为重量级锁: 如果多个线程尝试获得锁,或者自旋一定次数后仍然无法获得锁,轻量级锁会升级为重量级锁。
-
轻量级锁在多线程环境下提供了更好的性能,特别是在线程竞争不激烈的情况下,避免了线程阻塞和唤醒的开销。它减少了对系统级锁的使用,减轻了多线程并发竞争带来的性能损耗。然而,当竞争激烈或自旋次数过多时,会导致轻量级锁升级为重量级锁,这时就会带来与传统锁相似的性能开销。
3.1.3 偏向锁
偏向锁(Biased Locking)是 Java 中针对单线程访问同步块的一种优化机制。它旨在减少不必要的同步操作,提高没有竞争的场景下的性能。
- 特点包括:
-
针对单线程访问: 偏向锁假设一个对象在没有竞争的情况下,会被同一个线程多次访问。因此,当对象被初始化后,它会认为会被同一个线程多次访问,从而偏向于该线程。
-
记录线程 ID: 偏向锁会在对象头中记录持有锁的线程 ID。当线程再次访问该对象时,会直接获取锁,而不需要进行竞争。
-
撤销机制: 如果其他线程尝试访问已经偏向的对象,偏向锁就会失效,升级为轻量级锁。
-
适用范围: 适用于大部分情况下只有一个线程访问对象的场景,比如很多情况下对象的同步都是由单个线程访问的。
-
偏向锁的引入主要是为了解决大部分情况下是单线程访问共享资源的场景下,避免无谓的同步操作。因为在很多场景下,对象在初始化后,会被同一个线程多次访问,此时偏向锁能够提供较好的性能优化。不过,如果对象在多线程环境下被频繁访问,偏向锁的优化机制反而会增加性能开销,因为会频繁失效和撤销。
3.1.4 自旋锁
自旋锁是一种用于多线程同步的锁机制,在这种锁中,线程在尝试获取锁时不会立即被阻塞,而是会进行短暂的“忙等”(自旋)尝试获取锁。自旋锁在一定程度上避免了线程阻塞所带来的开销,特别是在锁被占用时间较短、并发程度不高的情况下能够提供更好的性能。
- 主要特点:
- 忙等: 线程在尝试获取锁时会不断地进行循环检查,而不是立即阻塞等待。
- 减少线程切换: 在短时间内争夺锁的情况下,自旋锁避免了线程频繁进入阻塞和唤醒状态所带来的系统开销。
- 限时自旋: 一些自旋锁支持限时自旋,即在一定时间内自旋未成功时,线程会进入阻塞状态,避免长时间的忙等。
在Java中,synchronized锁的优化就使用了自旋锁的思想。偏向锁和轻量级锁都是基于自旋锁的概念进行设计的。自旋锁能够在一定程度上提升并发性能,但在高并发、长时间持有锁的情况下,自旋锁可能会消耗大量的CPU资源。因此,在使用自旋锁时,需要根据实际场景进行合理的调整和使用。
3.2 Synchronized的锁优化
synchronized 关键字在 Java 中通过不断的优化来提高性能和并发处理效率。在不同的 Java 版本中,对于 synchronized 的优化有所不同。
- Java 6 以前的优化:
- 重量级锁优化: 在 Java 6 以前,synchronized 锁是重量级锁,它会引入用户态和内核态的切换,涉及线程的阻塞和唤醒,性能开销相对较大。
- Java 6 及之后的优化:
-
偏向锁和轻量级锁: 引入了偏向锁和轻量级锁,用于优化对于同步块的访问。
- 偏向锁: 用于表示对象被偏向于一个线程。在没有竞争的情况下,对象会偏向于第一个访问它的线程,避免了不必要的同步操作。
- 轻量级锁: 在多个线程访问同步块时,偏向锁会升级为轻量级锁。这时,通过自旋锁避免线程的阻塞,避免了用户态和内核态的切换。
-
自适应自旋:
- 对轻量级锁,Java 运行时会根据锁竞争情况自适应地调整自旋的次数,以提高性能。
- 针对重量级锁的竞争状态,未获取到锁的线程不会立即进入阻塞状态,而是尝试循环获取锁,也就是自旋优化,防止进入阻塞状态引起的用户态和内核态的切换,Java 运行时会根据锁竞争情况自适应地调整自旋的次数,以提高性能。
-
这些优化的引入旨在在没有竞争的情况下减少不必要的同步操作,并尽量减少线程的阻塞,提高并发性能。同时,当竞争激烈时,优化也使得锁能够升级为重量级锁,确保线程安全。这些优化机制让 synchronized 的性能得到了较大的提升。
3.3 Synchronized的锁优化原理
首先来看一个小故事,加深对锁优化的理解:
3.3.1 轻量级锁
- 轻量级锁的加锁原理如下:
- 轻量级锁的解锁原理如下:
- 总结如下:
3.3.2 锁膨胀
3.3.3 自旋优化
3.3.4 偏向锁
3.3.4.1 偏向状态
默认对象头如下:
调用对象的hashcode也会禁用偏向锁,因为hashcode与偏向锁的markword不是同一个,两者不能同时存在:
3.3.4.2 偏向撤销
修改代码如下:让两个线程交替执行,测试从偏向锁->轻量级锁
执行结果如下:
只有重量级锁才有wait/notify,故使用wait/notify必须要升级为重量级锁。
3.3.4.3 批量重偏向
3.3.4.4 批量撤销
3.3.5 锁消除
锁消除是指在编译器或运行时进行的一种优化技术,用于消除不必要的同步锁。这种优化技术的目的是提高性能,减少不必要的锁竞争和同步开销。
如何实现锁消除:
在Java中,JIT编译器或者运行时会对代码进行分析,识别出某些同步块中,由于上下文情况的确定,在并不会存在多线程竞争的情况下,可以安全地去除同步锁,从而避免不必要的同步操作。这个过程被称为锁消除。
举例说明:
public String concatenateString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在这段代码中,StringBuffer 是线程安全的,但是在这个方法中,sb 只在当前线程内部使用,不会被其他线程访问。因此,JIT 编译器可以进行锁消除,将sb 对象的同步锁去除,因为在这个上下文中并不存在多线程竞争,不会产生线程安全问题。
再例如如下代码:
由于方法b()里面的同步代码块锁的是一个局部对象,非共享的,这样加锁和不加锁没什么区别,会被即时编译器优化掉,即会优化消除锁。
结果对比如下:
锁消除的意义:
1. 减少不必要的同步开销,提高程序性能。
2. 避免因为过多同步锁导致的系统开销和性能下降。
锁消除是一种重要的优化技术,但需要确保在消除锁的情况下不会引入潜在的线程安全问题。因此,它通常是在编译器和运行时的静态和动态分析中进行的,只有在确定情况下才会执行锁消除操作。
3.3.6 锁优化原理总结
synchronized 锁在 Java 中经历了不断的优化,主要包括偏向锁、轻量级锁和重量级锁等不同的优化状态。这些优化状态是为了在不同场景下提供更高效的同步机制。
锁优化原理包括:
-
偏向锁(Biased Locking):
- 初始阶段,对象会被设置为偏向于第一个访问它的线程。当只有一个线程访问对象时,偏向锁能够提供低延迟的同步。
- 目的是为了在没有竞争的情况下,减少不必要的同步开销。
-
轻量级锁(Lightweight Locking):
- 在出现轻量级竞争的情况下,偏向锁会升级为轻量级锁。这时,会使用 CAS 操作来避免线程阻塞。
- 目的是为了避免频繁地阻塞和唤醒线程,在轻量级竞争的情况下提供更好的性能。
-
重量级锁(Heavyweight Locking):
- 当多个线程竞争同一个锁时,锁会升级为重量级锁,即传统的互斥同步。这时会涉及到操作系统层面的内核态的线程阻塞与唤醒。
- 目的是在激烈竞争的情况下,保证线程安全。
这些优化状态的转换,例如从偏向锁到轻量级锁,或者从轻量级锁到重量级锁,是根据竞争情况来决定的。Java 中的锁优化机制旨在提供更高效的同步机制,以适应不同程度的并发竞争,从而提高系统的性能。