浅谈synchronized
- 原子性
- 可见性
- 有序性
- Synchronized的使用方法
- JVM中Synchronized的优化(无锁、偏向锁、轻量级锁、重量级锁)
- Synchronized与Lock
- Synchronized使用注意事项
上次,在《浅谈Java并发编程》一文中,我们了解到,synchronized关键字可以用来解决并发编程中的原子性问题,其实,synchronized关键字不仅可以解决原子性问题,还可以解决有序性和可见性问题。那么synchronized关键字是怎么做到的呢?synchronized关键字如何使用,有什么优缺点呢?一起来看看吧。
原子性
synchronized
通过互斥锁机制保证原子性。当一个线程访问一个被synchronized
保护的代码块或方法时,它会获得相应的锁。在这个线程释放锁之前,其他线程无法进入相同的同步代码块或方法,从而保证了同时只有一个线程能够执行这段代码。- 在Java中,
synchronized
基于监视器(Monitor)实现,监视器与对象关联,可以绑定到对象的头上或者与对象一起存储在Java堆中。监视器确保了同步块或同步方法的执行是原子性的。 - 字节码中有
Monitorenter
和Monitorexit
指令,使对象的锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得; monitorexit指令
:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。
可见性
- 内存屏障 :
synchronized
操作隐含了内存屏障(memory barriers),这些屏障确保在线程释放锁之前,所有对该线程本地变量的修改都已经被刷新到主内存中。这样,当另一个线程获取同一个锁时,它能够看到共享变量的最新值。 - 缓存一致性 :由于
synchronized
涉及到监视器的获取和释放,这会触发缓存一致性协议,确保所有线程看到的共享变量值是一致的。 - 主内存 :被
synchronized
保护的变量的读写操作都发生在主内存中,而不是线程的工作内存中,这确保了所有线程都能看到变量的最新状态。
有序性
- 禁止重排序 :
synchronized
通过内存屏障禁止了编译器和处理器对同步代码块内的指令进行重排序。这包括在获取锁之前和释放锁之后的代码,确保了代码的执行顺序。 - happens-before关系:根据Java内存模型,对共享变量的解锁操作(happens-before)对后续的加锁操作可见。这意味着在一个线程释放锁之后,任何获取该锁的线程都能看到解锁前的所有操作结果。
- 同步块的执行顺序 :
synchronized
保证了在同一个锁上,前一个同步块执行完成并释放锁之后,下一个同步块才能获取锁并执行。这确保了同步块的执行顺序与代码中的顺序一致。
Synchronized的使用方法
-
在方法声明中设置synchronized同步关键字,保证其方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。
-
synchronized方法和synchronized同步块有什么区别呢?
- 总体来说,synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;
- 而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。
- 在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。
- 所以synchronized代码块比synchronized方法更加细粒度地控制了多个线程的同步访问。
-
下面两种实现多线程同步的plus方法版本编译成JVM内部字节码后结果是一样的。
-
版本一,使用synchronized代码块对方法内部全部代码进行保护,具体代码如下:
javapublic void plus() { synchronized(this){ //对方法内部全部代码进行保护 amount++; } }
-
版本二,使用synchronized方法对方法内部全部代码进行保护,具体代码如下:
javapublic synchronized void plus() { amount++; }
-
综上所述,synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁。
-
-
synchronized锁类:
- 使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。
- 由于类的对象实例可以有很多,但是每个类只有一个Class实例,因此使用类锁作为synchronized的同步锁时会造成同一个JVM内的所有线程只能互斥地进入临界区段。
-
synchronized锁的释放:通过synchronized关键字所抢占的同步锁什么时候释放呢?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。
JVM中Synchronized的优化(无锁、偏向锁、轻量级锁、重量级锁)
-
为什么优化?
- 简单来说,在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用Mutex Lock那么将严重的影响程序的性能,代价高、效率低。
- JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现。
-
在Java SE 1.6里Synchronied同步锁,一共有四种状态:
无锁
、偏向锁
、轻量级锁
、重量级锁
,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
-
四种锁状态:
-
无锁状态:Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态;
-
偏向锁状态:偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。
偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当作自己的熟人。
-
轻量级锁:当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。
当锁处于偏向锁,又被另一个线程企图抢占时,偏向锁就会升级为轻量级锁。企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能。
自旋锁的原理:自旋原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要进行内核态和用户态之间的切换来进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核切换的消耗。
适应性自旋锁:线程自旋是需要消耗CPU的,如果一直获取不到锁,那么线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK 1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
-
重量级锁会让其他申请的线程之间进入阻塞,性能降低。
-
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 追求响应时间 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量 |
Synchronized与Lock
-
synchronized的缺陷
效率低
:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时;不够灵活
:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活;无法知道是否成功获得锁
,相对而言,Lock可以拿到状态,如果成功获取锁,...,如果获取失败,...;- 非公平性 :
synchronized
是非公平锁,不能保证等待时间最长的线程优先获得锁,可能出现"饥饿"现象。 - 无法中断:在获取锁的过程中,线程无法响应中断,只能等待获取锁。
- 无法尝试锁定 :使用
synchronized
时,线程无法尝试获取锁,只能一直阻塞等待。
-
为了弥补
synchronized
的这些缺陷,Java提供了java.util.concurrent.locks.Lock
接口及其实现类,如ReentrantLock
,用于提供比synchronized
更灵活和强大的同步机制。Lock
接口提供了比synchronized
更多的功能,可以更精确地控制多线程并发访问。- 细粒度控制:Lock接口提供了更灵活的锁定和解锁机制,可以实现更细粒度的控制,允许更多个线程以不同的方式访问共享资源。
- 公平性:Lock接口的实现类可以支持公平锁和非公平锁,可以通过构造方法来指定锁的公平性。
- 可中断性 :Lock接口提供了
lockInterruptibly()
方法,允许线程在获取锁的过程中响应中断。 - 尝试锁定 :Lock接口提供了
tryLock()
方法,可以尝试获取锁而不会一直阻塞,可以设置超时时间。
综上所述,Java Lock提供了更多的特性和灵活性,可以更好地控制多线程并发访问,解决了synchronized
的一些缺陷,并且能够更好地满足复杂的并发需求。
Synchronized使用注意事项
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错