文章目录
synchronized关键字介绍
synchronized块是java提供的一种原子性内置锁,java中的每个对象都可以把它当作一个同步锁来使用。
synchronized的三种应用方式
-
修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
-
修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
-
修饰代码块,指定加锁对象,对给定对象(类或者指定对象)加锁,进入同步代码库前要获得给定对象的锁。
synchronized的内存语义
synchronized可以解决共享变量内存可见性问题。
进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的语义就是把在synchronized块内对共享变量的修改刷新到主内存。
synchronized的底层原理
修饰代码块
synchronized 同步语句块使用的是 monitorenter(对应JMM模型lock指令) 和 monitorexit (unlock)指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
修饰方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,有synchronized标识指明了该方法是一个同步方法。如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
总结:本质都是对对象监视器monitor的获取。
加锁时,对锁计数器+1;释放锁时,对锁计数器-1;锁计数器为0时,表示可以获取该锁。
Java虚拟机对synchronized的优化
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
锁升级
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时, 无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
所以,对于没有锁竞争 的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。
锁对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 若线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 若线程长时间竞争不到锁,自旋会消耗 CPU 性能 | 线程交替执行同步块或者同步方法,追求响应时间,锁占用时间很短,阻塞还不如自旋的场景 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,锁占用时间较长 |
synchronized特性
可重入
synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁啦,在执行完同步代码块时,计数器的数量会-1,知道计数器的数量为0,就释放这个锁。
不可中断
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
synchronized不可以被中断,指的是synchronized等待不可中断,处于阻塞状态的线程会一直等待锁。比如:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class InterruptTest {
public synchronized void foo1() {
System.out.println("foo1 begin");
for (int i =0; i < 5; ++i) {
System.out.println("foo1 ...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("foo1 sleep is interrupted, msg=" + e.getMessage());
}
}
}
public synchronized void foo2() throws InterruptedException {
System.out.println("foo2 begin");
for (int i =0; i < 100; ++i) {
System.out.println("foo2 ...");
Thread.sleep(1000);
}
}
public static void main(String[] args) {
InterruptTest it = new InterruptTest();
ExecutorService es = Executors.newCachedThreadPool();
es.execute(() -> it.foo1());
es.execute(() -> {
try {
it.foo2();
} catch (InterruptedException e) {
System.out.println("foo2 is interrupted, msg=" + e.getMessage());
}
});
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
es.shutdownNow();
System.out.println("Main end");
}
}
foo2的synchronized在等待foo1时不可被中断,只有在foo2拿到锁之后才可被中断,执行结果为:
foo1 begin
foo1 ...
foo1 ...
foo1 ...
Main end
foo1 sleep is interrupted, msg=sleep interrupted
foo1 ...
foo1 ...
foo2 begin
foo2 ...
foo2 is interrupted, msg=sleep interrupted
- 个人公众号
- 个人小游戏