java并发编程 ||深入理解synchronized,锁的升级机制


上一章我们说了多线程编程所带来的好处( java并发编程 ||Thread生命周期详解),但是既然有那么多好处,肯定也会带来一些问题,这一章我们就来看看它带来的问题以及解决的办法。

多线程所带来的问题?

线程不安全

1.首先我们举一个例子来证明线程的不安全

我们对一个数自增1000次,并且用多线程来实现。

  1. /**
  2. * @Author Dark traveler
  3. * @Note 我心净处,何处不是西天。
  4. * @Descrption
  5. * @E-Mail : 1029149772@qq.com
  6. * @Date : Created in 9:02 2020-3-18
  7. */
  8. public class NoSafeThread {
  9. private static int sum = 0;
  10. public static void incr(){
  11. try {
  12. Thread.sleep(1);
  13. } catch (InterruptedException e) {
  14. e.printStackTrace();
  15. }
  16. sum++;
  17. }
  18. public static void main(String[] args) {
  19. for(int i=0,len=1000;i
  20. new Thread(()->{
  21. incr();
  22. }).start();
  23. }
  24. //确保所有程序都运行完成了
  25. TimeUnit.SECONDS.sleep(2);
  26. System.out.println(sum);
  27. }
  28. }

我们会发现,我们一直得不到我们想要的答案1000,始终是一个小于1000的随机值,但是从逻辑上来说,分明应该是1000才对啊? 

这是为什么呢,对,这就代表了线程是不安全的,我们现在new了1000个线程来调用这个方法,很多线程可能会同时进入这个方法,也就意味着可能会有两个线程同时拿到sum = 0的初始值,然后同时进入方法incr(),并且线程1自增以后把sum赋值为1,但是线程2此时并没有拿到这个1,因为它们两个都进入了这个incr()方法,所以线程2又把0自增成为1,并且把sum再次赋值为1,这样就成为了1次重复操作,当很多次这种情况出现的时候,就出现了上面那种情况。

2.怎么解决线程不安全

既然出现了线程不安全,会让n个线程同时进入一个方法,那我们只要想办法同时只有一个线程进入这个方法,那就没问题了吧。所以java就引入了锁的概念,我们来看看怎么用锁把上面那个答案变回1000。

  1. /**
  2. * @Author Dark traveler
  3. * @Note 我心净处,何处不是西天。
  4. * @Descrption
  5. * @E-Mail : 1029149772@qq.com
  6. * @Date : Created in 9:02 2020-3-18
  7. */
  8. public class NoSafeThread {
  9. private static int sum = 0;
  10. private static Object lock = new Object();
  11. public synchronized static void incr(){
  12. try {
  13. Thread.sleep(1);
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. //锁定代码块
  18. /* synchronized (lock){
  19. sum++;
  20. }*/
  21. sum++;
  22. }
  23. public static void main(String[] args) throws InterruptedException {
  24. for(int i=0,len=1000;i
  25. new Thread(()->{
  26. incr();
  27. }).start();
  28. }
  29. //确保上面线程都运行完毕了
  30. TimeUnit.SECONDS.sleep(2);
  31. System.out.println(sum);
  32. }
  33. }

我们使用了关键字synchronized来完成对方法的上锁,使得每一时刻只有一个线程进入这个方法,这样就能确保线程安全了,那我们来看看synchronized关键字的用法。

3.synchronized关键字(重量级锁)

synchronized的三种使用方法,代码如下

 *修饰实例方法:demo()

 *修饰静态方法:demo3()

 *修饰代码块:demo2()

  1. /**
  2. * @Author Dark traveler
  3. * @Note 我心净处,何处不是西天。
  4. * @Descrption
  5. * @E-Mail : 1029149772@qq.com
  6. * @Date : Created in 11:03 2020-3-18
  7. */
  8. public class LockThread {
  9. //2种表现形式,放在方法层面和代码块层面来控制
  10. //2种作用范围,对象锁还是类锁 区别:是否跨对象跨线程被保护
  11. //修饰方法层面,对象锁
  12. public synchronized void demo(){
  13. }
  14. //也是对象锁,比较灵活
  15. public void demo2(){
  16. //todo
  17. synchronized (this){
  18. //保护存在线程安全的变量
  19. }
  20. }
  21. //加了static,全局锁
  22. public synchronized static void demo3(){
  23. }
  24. //也是全局锁
  25. public void demo4(){
  26. synchronized (LockThread.class){
  27. }
  28. }
  29. public static void main(String[] args) {
  30. LockThread lockThread1 = new LockThread();
  31. LockThread lockThread2 = new LockThread();
  32. //不是同一个对象,不存在互斥特性,不排队
  33. new Thread(()->lockThread1.demo()).start();
  34. new Thread(()->lockThread2.demo()).start();
  35. //存在互斥性,要排队
  36. new Thread(()->lockThread1.demo3()).start();
  37. new Thread(()->lockThread2.demo3()).start();
  38. }
  39. }

总结:

1.锁的共享性和互斥性,只有共享项相同的锁才会有互斥作用。

2.锁的两种表现形式,锁整个方法和锁相应的代码块。

3.锁的两种作用范围,锁单个对象实例和锁整个类。

synchronized为什么能起到锁的作用?锁在内存中是怎么存储的。

我们启用synchronized关键字的时候,会去取得这个锁的对象,我们先来看看一个对象在内存中的内存布局。

查看hotspot源码,我们可以看到对象头中具体放了哪些东西。

 

所以我们发现,锁其实都是放到对象头中的,如果我们使用 synchronized关键字的时候,它会去取得这个对象的头中的锁信息,通过这个锁中的信息来判断是否加锁。

4. 1.6jdk以后锁的升级

虽然使用synchronized关键字可以保证线程的安全,但是降低了效率,那既想保证线程安全,又想保证性能怎么办呢?所以jdk1.6以后,想到了一种锁升级的情况,只有到重量级锁的时候才阻塞,之前的锁都不阻塞。

无锁 -》 偏向锁 -》轻量级锁 -》重量级锁

首先来说下锁的升级流程,假设有两个线程ThreadA/ThreadB访问同步代码块

1.只有ThreadA去访问(大部分情况属于这种)-》引入偏向锁标记(ThreadA的ThreadId,偏向锁的标志)

2.ThreadA和ThreadB交替访问-》轻量级锁(自旋锁)

3.多个线程同时访问-》申请重量级锁,阻塞

无锁-》偏向锁:

当线程A去访问同步代码块的时候,先通过CAS来比较,实现原子性,检查对象头中是否存储了线程1,如果没有存储就通过cas来替换,把线程A的id加入锁对象头中,并且加上偏向锁的标志,获得偏向锁布局就变成下图。

注:

CAS 乐观锁,compare and swap(value,expect,update),就相当于判断下当前的值是不是最新的。我们通过下面的图来简单说明下cas的作用原理,假设我们当前内存中有个值为 int i =0,当下面的CAS开始时,先读取当前的值E = 0,然后计算这个值(比如计算过程为++),那么计算值  V = 1,接下来并不是直接赋值,而是再比较一次当前值E和现在内存中这个 i的值,现在叫N,是不是相等,如果是相等,就把计算值更新为V,CAS结束。

当然上面是最完美的状态,在这个比较当前值E和内存中的最新值N的时候,也会出现不相等的状态,那么当前CAS就会失败,不会去更新新值。

除了失败以外,还有一种可能,就是有其它线程对当前值进行了操作,比如另一个线程把这个值从0改成1,又从1改成了0,其它线程是操作过这个值的,但是在CAS中的比较却是能成立的,这就是CAS中的ABA问题,那么这种问题怎么解决呢,或者说怎么感知到呢,那就是加一个标志,可以是版本号,也可以是一个布尔值,当读取这个内存中的i的时候,把它的版本号一起取过来,只有有线程对它进行过操作,就升级一下版本号,这样就能解决ABA问题了。

偏向锁-》轻量级锁(自旋锁):

此时当线程B来访问同步代码块,它也会通过CAS来比较对象头中的锁,线程A和B的id当然不相同,所以这个比较一定会失败,然后它就会去把线程A暂停,并且把它的偏向锁给撤销,把这个锁对象的锁标志和线程id给情况,这里又会分为两种情况,如果线程A已经做完了同步代码块中的指令,A就直接把锁对象释放,由线程B来把线程id放进锁对象,访问同步代码块;如果线程A并没有做完同步代码块中的指令,那么就会升级成轻量级锁,然后两者以轻量级锁的方式来竞争这个锁对象。

轻量级锁-》重量级锁

当升级成轻量级锁以后,线程B会启动自旋,就是在一个循环中反复通过cas来获取锁。(因为绝大部分线程在获得锁以后会在非常短的时间内释放锁,所以这种情况阻塞损耗性能不值得)因为自旋也会占用cpu资源,所以在自旋到指定次数以后,还没有获得轻量级锁,锁就会膨胀成重量级锁,然后直接阻塞。

所以自旋就有两种形式:

1.设置自旋次数 preBliockSpin,jvm设置

2.自适应自旋,推荐,根据线程获得锁的自旋次数来调整,虚拟机知道你上一次自旋锁是成功的,那么它会觉得你下一次也很可能成功,所以它会自动调整自旋锁的次数。

注:

自旋锁,一个循环

for(;;){

    if(cas){

         return ;//获得锁成功

     }

重量级锁

升级到重量级锁以后,没有获取到锁的线程会被阻塞,blocked状态,我们来看看升级到重量级锁做了什么。

使用重量级锁后,我们发现对象里面有一个配对监视器,3和5,表示拿到锁和释放锁,11是异常也释放锁,这是一个对象监视器,ObjectMonitor。

 看hotspot,我们会发现每个对象都存在一个ObjectMonitor,这也是重量级锁的核心。

monitor -》MutexLock,把用户态改变成内核态,基于操作系统底层来实现互斥,所以这也是比较耗费性能的原因。

也就是说重量级锁后,一个线程拿到对象监视器,成功后调用指令monitorenter,然后操作同步代码块,其它线程就被放到一个同步队列中,直到完成后调用指令monitorexit来唤醒同步队列中的一个。

总结:

偏向锁-》轻量级锁:一个是通过cas,原子替换;一个是自旋来不断尝试,不会阻塞线程,所以性能高。

重量级锁:通过阻塞来加锁,性能较低。

synchroized关键字把这些都封装到了jvm中,所以越简单的使用,底层的实现其实越复杂。

5、wait、notify、notifyall

线程的通信机制

我们用个代码演示一下:

  1. /**
  2. * @Author Dark traveler
  3. * @Note 我心净处,何处不是西天。
  4. * @Descrption
  5. * @E-Mail : 1029149772@qq.com
  6. * @Date : Created in 15:22 2020-3-18
  7. */
  8. public class ThreadA extends Thread{
  9. private Object lock;
  10. public ThreadA(Object lock){
  11. this.lock = lock;
  12. }
  13. @Override
  14. public void run() {
  15. synchronized (lock){
  16. System.out.println("start ThreadA");
  17. try {
  18. lock.wait();//实现线程的阻塞
  19. } catch (InterruptedException e) {
  20. e.printStackTrace();
  21. }
  22. System.out.println("end ThreadA");
  23. }
  24. }
  25. }
  26. /**
  27. * @Author Dark traveler
  28. * @Note 我心净处,何处不是西天。
  29. * @Descrption
  30. * @E-Mail : 1029149772@qq.com
  31. * @Date : Created in 15:22 2020-3-18
  32. */
  33. public class ThreadB extends Thread{
  34. private Object lock;
  35. public ThreadB(Object lock){
  36. this.lock = lock;
  37. }
  38. @Override
  39. public void run() {
  40. synchronized (lock){
  41. System.out.println("start ThreadB");
  42. lock.notify();//实现线程的唤醒
  43. System.out.println("end ThreadB");
  44. }
  45. }
  46. }
  47. /**
  48. * @Author Dark traveler
  49. * @Note 我心净处,何处不是西天。
  50. * @Descrption
  51. * @E-Mail : 1029149772@qq.com
  52. * @Date : Created in 15:26 2020-3-18
  53. */
  54. public class waitNotifyDemo {
  55. public static void main(String[] args) {
  56. Object lock = new Object();
  57. ThreadA threadA = new ThreadA(lock);
  58. threadA.start();
  59. ThreadB threadB = new ThreadB(lock);
  60. threadB.start();
  61. }
  62. }

wait:实现线程的阻塞,并且释放当前的同步锁

motify/notifyall:唤醒被阻塞的单个线程(全部线程)

用图来表示一下刚刚的流程