一.synchronized 的作用
在并发编程中存在多个线程访问共享数据的情况,多线程操作共享数据,就会引发线程安全问题,而synchronized是Java提供的最基础也是最核心的线程同步机制它的作用有三原子性,可见性以及有序性。
<1>.被synchronized修饰的类或对象的所有操作都是原子的,在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
<2>.synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中。
<3>.synchronized保证了每个时刻都只有一个线程访问同步代码块,确定线程执行同步代码块是分先后顺序的。
二.synchronized的使用
synchronized也是有3种使用方法
<1>.修饰实例方法(锁对象是当前实例(this))
由于它是作用在同一个对象实例,所以当多个线程访问不同对象时不会互斥,而同一线程可重复调用该实例的其他同步实例方法没有无死锁风险。但其灵活性低不能只锁定方法内的核心代码段,哪怕只有 1 行代码需同步,也会锁住全方法,并发效率低。
// 它在实例方法前加 synchronized 关键字即可
public class SyncDemo {
private int num;
// 创建synchronized修饰的实例方法
public synchronized void updateNum(int newNum) {
this.num = newNum; // 线程安全的实例变量修改
}
}
<2>.修饰代码块(任意对象)
在有些业务场景下,方法涉及的业务较多,但是又只有部分代码需要同步,如果锁住整个方法,显然是不太合理的,这个时候就可以使用同步代码块,它仅仅锁定了大括号内的代码,方法其余的部分仍可以并发的执行且锁的对象可自定义,避免了无效的同步提升我们的效率,在实际开发中是优先考虑的。
public class SyncBlockDemo {
private int num;
public void updateNum(int newNum) {
// synchronized(锁对象) { 需同步的代码段 }
// 锁对象常用this或者独立Object,我这里this(和实例方法锁一致)
synchronized (this) {
this.num = newNum; // 仅锁定这行核心代码
}
}
}
<3>.修饰静态方法(当前类的 Class 对象)
它与实例的类似,都只需关键字即可,它一锁就锁定整个class对象,所有实例或线程调用该方法都互斥,同时它由JVM 兜底释放(即使抛异常也释放),所以也无死锁风险,但对应的它无法仅锁定方法内关键代码,一个静态同步方法被锁定时,该类的其他静态同步方法也会被阻塞,所以它的灵活性不好且影响范围广。
public class SyncStaticMethodDemo {
private static int total;
public static synchronized void addTotal(int num) {
total += num;
}
}
三.synchronized 的底层原理
它的底层是由 Java 对象头和 JVM 内置的监视器(Monitor)实现,核心逻辑是通过对象头存储锁状态,结合锁升级机制在用户态与内核态完成同步。
用户态与内核态是CPU运行时的两种级别,用户态即为普通程序运行模式,无系统资源操作权限,切换成本极低;而内核态为操作系统核心模式,可操作内存、线程等核心资源,切换成本极高(需保存和恢复线程上下文)。
每个 Java 对象在内存中都自带元数据标签即对象头,它独立于业务属性存在,仅负责记录对象的核心元信息,它也是synchronized 锁状态的唯一存储容器;主要由两部分组成,第一部分是 Mark Word,固定8字节,是对象头的核心,它会随锁状态动态变化,同时承载三类关键信息一是对象哈希值,二是分代年龄(用于垃圾回收判定),三就是锁状态相关信息,它通过最后 2~3 位标识锁状态,不同锁状态下,Mark Word 存储的内容是完全不同的。第二部分是 Klass Pointer(类型指针),它关闭指针压缩时会占 8 字节,而开启后占 4 字节,它的作用是指向对象所属类的元数据。比如我们User user=new User()时这个指针就指向User.class,JVM 靠它判断对象的类型知道 user 是 User 类的实例,才能正确调用 User 类的方法。而Java对象头本质都是修改 Mark Word 的内容,同时通过 Klass Pointer 来保证对象类型正确。
Monitor又称管程,它是JVM内置的内核级同步工具。每个 Java 对象创建时,JVM 都会为其关联一个隐式 Monitor当锁升级为重量级锁时,对象头的 Mark Word 会指向这个 Monitor,由它负责线程的竞争、阻塞与唤醒,所以它是 synchronized 重量级锁的唯一实现载体。它们可简单理解为对象头的 Mark Word 负责 “记录锁状态”,Monitor 负责 “执行锁调度”;当锁升级为重量级锁时,Mark Word 会通过指针指向 Monitor,将线程竞争逻辑交给 Monitor 处理。下图为32位虚拟机对象头分配情况
四.synchronized的优化历史
首先在JDK1.0~1.1时,synchronized是纯粹的重量级锁,无论是否有现成竞争,都会直接关联底层操作系统的Monitor锁。线程竞争锁时,从用户态切到内核态,无竞争时也不会释放资源,性能极差,即使是单线程执行同步代码,也会因为Monitor相关联而产生而外的开销。
在JDK1.2时,针对1.0~1.1锁持有僵化的这个问题,优化了它的Monitor的释放逻辑,当持有锁的线程执行完临界区的代码后,会主动的唤醒EntryList中等待的线程,减少部分无效等待,但是内核态和用户态转换的开销问题并没有解决
在JDK1.5的时候,推出了java.util.concurrent包,虽然没有对synchronized本身进行一个直接的优化,但是为它提供了更灵活的锁机制(可中断锁,公平锁,条件变量),虽然只是间接的优化,但是它为JDK1.6的优化指明了方向
而在JDK1.6也是synchronized优化的一个重要节点,它引入了锁升级的机制(无锁 → 偏向锁 → 轻量级锁 → 重量级锁且不可逆)和大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术。
1偏向锁.它的引入和轻量级锁引入的目的很类似,都是为了在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,而偏向锁在无竞争的情况下会使用CAS操作去替代互斥量,而偏向锁则是会把整个同步消除掉。它会偏向于第一个获得它的那个线程,在接下来执行中,如果这个锁没有被其他线程获取,那持有偏向锁的线程就不用同步。而如果在竞争比较激烈的话,可能偏向锁可能每次申请锁的线程都不是相同的,所以最好注意一下使用的场景。
2轻量级锁.在JDK1.6以后,如果偏向锁失败了,虚拟机不会立刻把它升级为重量级锁,它是会尝试使用轻量级锁的优化手段。它的出现本意如介绍偏向锁时所说,为了减少传统操作的性能消耗。使用轻量级锁时,不需要申请互斥量且轻量级锁的加锁和解锁都用到CAS操作。如果没有竞争的话,轻量级锁使用CAS操作可以避免互斥操作的开销,但如果有锁竞争的话,除了互斥量开销外,还会发生CAS操作,所以如果有锁竞争的话,轻量级锁会比重量级锁慢,而在锁竞争激烈的场景,那么轻量级锁会迅速膨胀为重量级锁。
3自旋锁与自适应自旋.在轻量级锁失败后,虚拟级为避免线程真实在操作系统层面挂起(挂起和恢复线程都要切换用户态转换到内核态),会进行自旋锁的优化。而一般的线程持有锁的时间不会太长,所以如果为了这点时间而去挂起的话是得不偿失的,而虚拟机的开发团队则提出让后面请求获取锁的线程等待一会而不被挂起,再看持有锁的线程是否很快释放锁的思想。那为了让一个线程等待,只需让线程执行一个自旋(可理解为一个死循环)。虽然是在JDK1.6之前引入但是默认是关闭的,是要手动打开,且默认自旋的值是10。而在JDK1.6及以后就默认开启了,且1.6还引入了自适应的自旋锁,来让自旋的时间不再是固定的,而是和前一次同一个锁上的自旋时间以及锁的拥有者状态来决定。
4锁消除.它是指虚拟机即使在编译器运行时,如果检测出共享数据不可能存在竞争,那么会执行锁消除,来节省没有意义的请求锁的时间。
5锁粗化.原则上我们编写代码时,总是会推荐将同步块的作用范围限制得尽量小,如果存在锁竞争,那等待线程也可以尽快拿到锁。大部分情况下,原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。
在JDK1.7中,优化了偏向锁的撤销逻辑,减少安全点(Safe Point)的暂停时间,以及微调了自适应自旋的阈值和锁粗化的检查范围,提升锁消除的识别率减少误判。
JDK1.8中,引入了批量重偏向(同一个类的多个对象偏向锁撤销后,直接将新对象偏向新线程),批量销毁(某类对象的偏向锁频繁撤销的话直接关闭该类的偏向锁),解决多个线程交替获取同一类的不同对象锁,导致大量偏向锁撤销的问题。同时针对多核CPU,把自旋的单线程循环改成了基于CPU核心的分片自旋和重量级锁的等待队列唤醒策略改为公平性优先的唤醒。
而JDK15,因为现代业务多为多线程并发场景,单线程持有锁的场景不多,导致偏向锁的适用场景有限,且偏向锁的撤销和重偏向逻辑复杂,若出现了轻微竞争,撤销开销甚至会远超出单线程获取的受益,所以将偏向锁标记为废弃把Synchronized的核心锁体系变为轻量级锁 → 重量级锁。
最后JDK17和21都是在JDK15的基础上进一步的优化Synchronized的锁体系,首先就是把偏向锁的相关代码直接移除,简化对象头Mark Word的状态逻辑,接着就是把传统的Monitor替换成基于FIFO队列的轻量级Monitor来减少内核态切换的耗时,同时针对超核CPU(32/64或者以上),实现自旋线程的核心绑定再结合虚拟线程和Synchronized的兼容,让虚拟线程获取重量级锁时,不会阻塞物理线程。
五.AQS
AQS全称AbstractQueuedSynchronized 抽象队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch
AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。对于state的访问有getState(),setState(),compareAndSetState()。
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
1 isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
2 tryAquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
3 tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
4 tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
5 tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其他线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证state是能回到零态的。
再以CountDownLatch为例,任务分为N个子线程去执行,state为初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
六.Java中锁的种类
- 乐观锁/悲观锁
- 独享锁/共享锁
- 互斥锁/读写锁
- 可重入锁
- 公平锁/非公平锁
- 分段锁
- 偏向锁/轻量级锁/重量级锁
- 自旋锁 这些都是对锁的一些名词,有的指锁特性有的指设计,对于偏向锁/轻量级锁/重量级锁/自旋锁上已有简单介绍。
1.乐观锁/悲观锁
乐观锁:名字上理解就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是会在更新的时候去判断在此期间别人有没有去更新这个数据,它一般会用CAS操作或者数据版本控制器来实现。适用于读多冲突概率低的场景
悲观锁:假设最坏的情况,每次去拿数据都认为别人会修改,所以每次拿数据都会上锁,对于同一份数据的并发操作,在对任意记录修改前,会先尝试给这条记录加上排他锁,假如失败,则说明该记录在被修改,当前查询会等待或者抛出异常等自定义的响应,反之则可对记录进行修改完成后会解锁,期间其他对这条记录做修改或加排它锁都要等待我这条线程解锁或抛出异常。它则适用于写多,冲突概率高的场景。
2.独享锁/共享锁(互斥锁/读写锁)
独享锁也叫排他锁,写锁等是指该锁一次只能被一个线程持有加锁后任何线程再次加锁都会被阻塞,而共享锁也叫读锁,可以查看数据,但不能修改与删除的一种数据锁,该锁一次可以被多个线程持有,用于资源数据共享。上面描述的synchronized就是独享锁,Lock类的实现ReentranLock也是独享锁,而另一个实现ReadWriteLock的读锁则是共享的写锁是独享的,且为保证并发读是高效的,读写,写读,写写的过程都互斥。而他们都是通过AQS来实现。
3.可重入锁
可重入锁又叫递归锁,指在同一个线程在外层方法获取锁时,在进入内存方法也会自动获取锁,synchronized 和 ReentrantLock 都是可重入锁,可以通过下面代码简单理解
//synchronized
public class syz {
public static void main(String[] args) {
Object obj = new Object();
new Thread(() -> {
// 第一次加锁
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "线程执行第一层");
// 第二次加锁,此时obj对象处于锁定状态,但是当前线程仍然可以进入,避免死锁
synchronized (obj) {
// 抛异常
int a = 10 / 0;
System.out.println(Thread.currentThread().getName() + "线程执行第二层");
}
}
}, "t1").start();
new Thread(() -> {
// 第一次加锁
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "线程执行第一层");
// 第二次加锁,此时obj对象处于锁定状态,但是当前线程仍然可以进入,避免死锁
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "线程执行第二层");
}
}
}, "t2").start();
}
}
可以看出t1线程第二层有异常,使用synchronized,发生异常会自动释放锁让t2线程正常输出。
//ReentrantLock
public class syzTest {
public static void main(String[] args) {
// 非公平锁
Lock lock = new ReentrantLock(false);
new Thread(() -> {
// 第一次加锁
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + "线程执行第一层");
// 第二次加锁,此时obj对象处于锁定状态,但是当前线程仍然可以进入,避免死锁
lock.lock();
try {
// 抛异常
int a = 10/0;
System.out.println(Thread.currentThread().getName() + "线程执行第二层");
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
},"t1").start();
new Thread(() -> {
// 第一次加锁
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + "线程执行第一层");
// 第二次加锁,此时obj对象处于锁定状态,但是当前线程仍然可以进入,避免死锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "线程执行第二层");
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
},"t2").start();
}
}
它的运行结果与上图一致,但是观察ReentrantLock类中源码注释可以发现,Lock是显示锁,必须手动开启和关闭锁,而忘记关闭会导致死锁的发生,当t1线程第二层有异常,我们释放锁写在finally里边,就不影响t2线程正常输出
4.公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,非公平锁则是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。而对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
5.分段锁
它是一种锁的设计,而不是具体的锁,主要想细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。我们可以结合下面ConcurrentHashMap来说下分段锁
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。