文章目录
一、synchronized的特性
(1)互斥
synchronized 会起到互斥效果,某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。synchronized 通过互斥达到原子性。
- 进入 synchronized 修饰的代码块,相当于 加锁
- 退出 synchronized 修饰的代码块,相当于 解锁
synchronized用的锁是存在Java对象头里的,它的底层是使用操作系统的mutex lock实现的。
一个线程上了锁之后,其他线程就只能等待这个线程释放锁(这个过程不可中断,只能一直等待,直到加锁为止),上一个线程解锁之后, 下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”, 这也就是操作系统线程调度的一部分工作。
假设有 A B C 三个线程,线程 A 先获取到锁,然后 B 尝试获取锁, 然后 C 再尝试获取锁,此时 B 和 C 都在阻塞队列中排队等待。但是当 A 释放锁之后,虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁,而是和 C 重新竞争,并不遵守先来后到的规则。
(2)刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
通过这个过程, synchronized保证了内存可见性。
(3)可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
什么是把自己锁死?
即一个线程没有释放锁, 然后又尝试再次加锁。
例如:
按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作,这时候就会死锁。这样的锁成为不可重入锁,synchronized是可重入锁,不包含这样的问题。
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息:
- 如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增。
- 解锁的时候计数器递减为 0 的时候,才真正释放锁。
(4)非公平锁和不可中断
这也是synchronized的特性。
非公平锁即上述举到的例子:假设有 A B C 三个线程,线程 A 先获取到锁,然后 B 尝试获取锁, 然后 C 再尝试获取锁,此时 B 和 C 都在阻塞队列中排队等待。但是当 A 释放锁之后,虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁,而是和 C 重新竞争,并不遵守先来后到的规则。
不可中断即:一个线程上了锁之后,其他线程就只能等待这个线程释放锁(这个过程不可中断,只能一直等待,直到加锁为止)。
二、synchronized的使用
(1)直接修饰普通方法
锁的是对象(单个对象内部加锁):
public class SynchronizedDemo {
public synchronized void methond() {
}
}
(2)修饰静态方法
锁的是类的所有对象:
public class SynchronizedDemo {
public synchronized static void method() {
}
}
(3)修饰代码块
明确指定锁哪个对象:
锁当前对象:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
锁类对象:
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
只有两个线程竞争同一把锁,才会有锁冲突,才会产生阻塞等待。
三、synchronized的锁机制
(1)加锁过程(锁升级)
JVM将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
如图:
偏向锁:
第一个尝试加锁的线程,优先进入偏向锁状态。
偏向锁不是真的 “加锁”,只是给对象头中做一个 “偏向锁的标记”,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。偏向锁本质上相当于 “延迟加锁”,能不加锁就不加锁,尽量来避免不必要的加锁开销。但是该做的标记还是得做的,否则无法区分何时需要真正加锁。
轻量级锁:
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)。
此处的轻量级锁就是通过 CAS 来实现。
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功,则认为加锁成功
- 如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃 CPU)
PS:自旋操作是一直让 CPU 空转,比较浪费 CPU 资源,因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 "自适应“。
重量级锁:
如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁。
此处的重量级锁就是指用到内核提供的 mutex锁
- 执行加锁操作,先进入内核态
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用,则加锁成功,并切换回用户态
- 如果该锁被占用,则加锁失败, 此时线程进入锁的等待队列挂起,等待被操作系统唤醒
- 经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒
这个线程,尝试重新获取锁
(2)优化操作
锁消除:
编译器+JVM 会判断锁是否可消除,如果可以,就直接消除。
例如:
有些应用程序的代码中,用到了synchronized,但其实没有在多线程环境下。
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码,那么这些加
锁解锁操作是没有必要的,白白浪费了一些资源开销。
锁粗化:
一段逻辑中如果出现多次加锁解锁,编译器 + JVM 会自动进行锁的粗化。
锁的粒度: 粗和细
实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁,这种情况 JVM 就会自动把锁粗化,避免频繁申请释放锁。