likes
comments
collection
share

绕不过的并发编程--synchronized原理

作者站长头像
站长
· 阅读数 27

简单介绍

并发编程的三大问题

什么是并发编程的三大问题?为什么有这些问题?具体的例子呢?

在我的博客:从零开始的JVM学习--Java内存模型(JMM)中有提到并发编程的三个基本问题:

  • 可见性

    什么是可见性?

    「可见性」是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

    为什么有可见性问题?

    • 从计算机底层的角度来看

      对于如今多核处理器,每个CPU有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU缓存和内存的数据不容易保证一致。

    • 从Java内存模型的角度来看

      JMM规定了所有变量都存储在内存中,每条线程有自己的工作内存

      线程的工作内存中保存的是该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作必须在工作内存中进行,不能直接读写主内存。 线程对变量的副本的写操作,不会马上同步到主内存

      不同线程之间也无法直接访问对方工作内存中的变量 ,线程间变量的传递需要自己的工作内存和主存之前进行数据同步。

  • 原子性

    什么是原子性?

    「原子性」是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    为什么有原子性问题?

    • 从计算机底层的角度来看

      线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。

      当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU的使用权,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程执行同一段代码,也就是原子性问题。

    • 从Java内存模型角度来看

      JMM实际上保证了对基本数据类型的变量的读取和赋值操作都是原子性操作的:

       a = true;  //原子性
       a = 5;     //原子性
      

      但是我们实际写代码经常需要用的操作比如:

       a = b;     //非原子性,分两步完成,第一步加载b的值,第二步将b赋值给a
       a = b + 1; //非原子性,分三步完成
       a ++;      //非原子性,分三步完成
      

      会导致原子性问题。

    原子性问题例子

    模拟场景:

    10个线程将a加到10000(保证可见性)

    测试代码:

     public volatile int a=0;
     ​
     public void increase(){
         a++;
     }
     ​
     @Test
     public void testAtomFeature(){
         for (int i = 0; i < 10; i++) {
             new Thread(()->{
                 for (int j=0;j<1000;j++){
                     increase();
                 }
             }).start();
         }
         while(Thread.activeCount()>2){
             Thread.yield();
         }
         System.out.println(a);;
     }
     ​
    

    运行结果:

     9905
    

    解释:

    由于a++总共是三个操作,首先从工作内存中读取变量副本到操作数栈中,然后再进行自增运算,最后写回到线程工作内存中。如果3个操作的空挡被别的线程干扰,则很可能丢失自增机会。例如下面场景:

     线程1从共享内存中加载a的初始值0到工作内存
     线程1对工作内存中的a的副本值加1
     线程1的CPU时间片耗尽,线程2获得执行机会
     线程2从共享内存中加载a的初始值到工作内存,此时a的值还是0
     线程2对工作内存中的a的副本值加1,此时线程2工作内存中的副本值是1
     线程2将a的副本值刷新回共享内存,此时共享内存中a的值是1
     线程2的CPU时间片耗尽,线程1获得执行机会
     线程1将工作内存中的副本值刷新回共享内存,但是此时线程1副本的值还是1,所以最后共享内存中的值也是1
    

    (保证可见性情况下,变量被修改完成确实是线程之间同步可见的,但是修改过程不原子的话就读的就可能是脏数据了)

  • 有序性

    什么是有序性?

    「有序性」是指程序执行的顺序按照代码先后执行。

    为什么有有序性问题?

    • 指令重排序

      如果没有「指令重排序」的话就不会有有序性问题了。

      • 什么是指令重排序?

        编译器和处理器在不影响代码单线程执行结果的前提下,对源代码的指令进行重新排序执行。

        这种重排序执行是一种优化手段,目的是为了处理器内部的运算单元能尽量被充分利用,提升程序的整体运行效率。

      • 重排序分成以下几种

        绕不过的并发编程--synchronized原理

        • 编译器优化的重排序

          编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

        • 指令级并行的重排序

          现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

        • 内存系统的重排序

          由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    • 有序性问题例子

      指令重排只保证单线程执行下的正确性,在多线程环境下,指令重排就会带来一定问题。

       private int value = 1;
       private boolean started = false;
       ​
       public void startMakeValue2(){
           value = 2;
           started = true;
       }
       ​
       public void checkValue(){
           if (started) {//关注点
               if(value==1)
                   log.debug("有序性问题出现");
           }
       }
      

      我们试想一下,我们有一个线程执行startMakeValue2,然后另一个线程在执行checkValue

      我们其实不能保证代码执行到关注点处我们的value就是2了,由于在startMakeValue2中两个赋值语句并不存在依赖关系,所以编译器在编译时可能进行指令重排,所以可能会先执行started=truestarted变成true后另一个线程立马执行checkValue方法,这个时候value就是1,于是出现有序性问题!

    • 关于有序性问题还有一个知识点就是happens-before原则,不过和volatile关键字的关联比较深,于是会放到volatile章节再谈。

synchronized详解

synchronized作用

synchronized可以解决并发编程的三大问题

synchronized关键字可以解决可见性,原子性,有序性问题:

  • 可见性问题解决

    「可见性」是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

    如何解决?

    synchronizedvolatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。

    简单来说就是确保了下一个线程获得锁前,本线程的修改已经同步到了内存。

  • 原子性问题解决

    「原子性」是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    如何解决?

    synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。

    简单来说就是一次只有一个线程获得锁,所以一个线程的操作可以完整的执行(不受干扰)。

  • 有序性问题解决

    「有序性」是指程序执行的顺序按照代码先后执行。

    如何解决?

    synchronizedvolatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。

    synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

synchronized使用

synchronized 关键字最主要的三种使用方式

  • 修饰实例方法

    用于给当前对象实例加锁进入同步代码前要获得当前对象实例的锁。

     synchronized void method(){
         // ...
     }
    
  • 修饰静态方法

    用于给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。

    因为静态成员不属于任何一个实例对象,是类成员。

    所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,不会发生互斥现象。

    因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

     synchronized void static method(){
         // ...
     }
    
  • 修饰代码块

    指定加锁对象,用于给指定对象/类加锁synchronized(this|object) 表示进入同步代码块前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

     sychronized(this){
         // ...
     }
    

总结一下:

synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。

synchronized 关键字加到实例方法上是给对象实例上锁。

加锁和释放锁的原理

synchronized实现原理

  • 从底层指令的角度来看

    synchronized的功能是基于monitorentermonitorexit指令实现的。

    monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit插入到方法结束处和异常处JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

    任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

    • 例子

       public class Hello {
           public static void main(String[] args) {
               synchronized (Hello.class){
                   System.out.println("Hello");
               }
           }
       }
      

      使用 javap -c Hello 可以查看编译之后的具体信息。

        public static void main(java.lang.String[]);
           descriptor: ([Ljava/lang/String;)V
           flags: ACC_PUBLIC, ACC_STATIC
           Code:
             stack=2, locals=3, args_size=1
                0: ldc           #2                  // class com/dyh/basic/Hello
                2: dup
                3: astore_1
                4: monitorenter
                5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
                8: ldc           #4                  // String Hello
               10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
               13: aload_1
               14: monitorexit
               15: goto          23
               18: astore_2
               19: aload_1
               20: monitorexit
               21: aload_2
               22: athrow
               23: return
             Exception table:
                from    to  target type
                    5    15    18   any
                   18    21    18   any
             LineNumberTable:
               line 5: 0
               line 6: 5
               line 7: 13
               line 8: 23
             StackMapTable: number_of_entries = 2
               frame_type = 255 /* full_frame */
                 offset_delta = 18
                 locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
                 stack = [ class java/lang/Throwable ]
               frame_type = 250 /* chop */
                 offset_delta = 4
       }
      

      可以看到同步块的入口和出口分别有monitorentermonitorexit

  • 从面向对象角度来看

    JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。

    (具体实现就是上面讲的,在编译之后在执行同步方法前加入一个 monitorenter 指令,在退出方法和异常处插入 monitorexit 的指令)

    其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。

    而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitorexit 之后才能尝试继续获取锁。

    绕不过的并发编程--synchronized原理

    该图可以看出,任意线程对Object的访问,首先要获得Object的监视器(Monitor),如果获取失败,该线程就进入同步队列(_EntrySet),线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

对象锁(monitor)机制

什么是monitor

monitor直译过来是监视器的意思,专业一点叫管程。

monitor是属于编程语言级别的,它的出现是为了解决操作系统级别关于线程同步原语的使用复杂性,类似于语法糖,对复杂操作进行封装。而Java则基于monitor机制实现了它自己的线程同步机制,就是synchronized内置锁。

monitor有什么用?

monitor的作用就是限制同一时刻,只有一个线程能进入monitor框定的临界区,达到线程互斥,保护临界区中临界资源的安全,这称为线程同步使得程序线程安全。

作为同步工具,monitor也提供了管理进程,线程状态的机制(比如monitor能管理因为线程竞争未能第一时间进入临界区的其他线程,并提供适时唤醒的功能。)

对象锁(monitor)原理

每个对象都存在一个与之关联的monitor。这也就是我们习惯说「对象锁」的原因。

monitor因为一次只有一个线程能拥有,所以是线程私有的数据结构。

每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联

通过我们上面讲的monitorenter指令就是用于线程尝试获得对象的monitor

  • monitor源码

    monitor由C++实现,源码如下:

       // initialize the monitor, exception the semaphore, all other fields
     ​
       // are simple integers or pointers
     ​
       ObjectMonitor() {
     ​
         _header       = NULL; // _header是一个markOop类型,markOop就是对象头中的Mark Word
     ​
         _count        = 0; // 抢占该锁的线程数 约等于 WaitSet.size + EntryList.size
     ​
         _waiters      = 0, // 等待线程数
     ​
         _recursions   = 0; // 锁重入次数
     ​
         _object       = NULL; // 监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中
     ​
         _owner        = NULL; // 指向获得ObjectMonitor对象的线程或基础锁
     ​
         _WaitSet      = NULL; // 处于wait状态的线程,被加入到这个linkedList
     ​
         _WaitSetLock  = 0 ; // protects Wait Queue - simple spinlock ,保护WaitSet的一个自旋锁(monitor大锁里面的一个小锁,这个小锁用来保护_WaitSet更改)
     ​
         _Responsible  = NULL ; 
     ​
         _succ         = NULL ; // 当锁被前一个线程释放,会指定一个假定继承者线程,但是它不一定最终获得锁。
     ​
         _cxq          = NULL ; // ContentionList
     ​
         FreeNext      = NULL ; 
     ​
         _EntryList    = NULL ; // 未获取锁被阻塞或者被wait的线程重新进入被放入entryList中
     ​
         _SpinFreq     = 0 ; // 可能是获取锁的成功率
     ​
         _SpinClock    = 0 ;
     ​
         OwnerIsThread = 0 ; // 当前owner是thread还是BasicLock
     ​
         _previous_owner_tid = 0; // 当前owner的线程id
     ​
       }
    

    ObjectMonitor的结构中可以看出主要维护_WaitSet以及_EntryList两个队列来保存ObjectWaiter对象。

    当每个阻塞等待获取锁的线程都会被封装成ObjectWaiter对象来进行入队,与此同时如果获取到锁资源的话就会出队操作。

    两个队列的含义不一样,里面存放的线程对象是根据代码操作入队的。(含义的解释在下面的「流程」中会说明)

    另外 _owner则指向当前持有ObjectMonitor对象的线程

  • 获取释放锁指令和monitor的关系

    monitorenter会让对象的锁计数器+1

    monitorexit会让对象的锁计数器-1

    每一个对象在同一时间只有一个monitor和它相关联,同时monitor在同一时间只能被一个线程获得。

    一个线程在尝试获取和对象相关联的锁(执行monitorenter)的时候会出现以下3种情况:

    • monitor计数器为0,意味着该monitor目前还没有被获得,那这个线程就会立刻获得,然后把锁计数器+1。对象的锁计数器>0,别的线程再想获取该对象锁,就需要等待。
    • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加。
    • 这把锁已经被别的线程获取了,则需等待锁释放。
  • 等待获取锁 以及 获取锁出队 流程

    线程「等待获取锁」以及「获取锁出队」的示意图如下图所示:

    绕不过的并发编程--synchronized原理

    多个线程想要获取锁的时候,首先都会进入(enter)到_EntryList队列

    其中一个线程获取(acquire)到对象的monitor后,_owner变量会被设置为当前线程,并且monitor维护的计数器加1。

    如果当前线程执行完逻辑并退出后(release and exit),monitor_owner变量就会清空并且计数器减1,这样就能让其他线程能够竞争到monitor

    另外,如果调用了wait()方法后,当前线程就会释放锁(release)并且进入到_WaitSet中等待被唤醒。如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到monitor

可重入锁

什么是可重入?

若一个程序或子程序可以「在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错」,则称其为「可重入」(reentrantre-entrant)的。

即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。

与多线程并发执行的线程安全不同,「可重入」强调对单个线程执行时重新进入同一个子程序仍然是安全的

什么是可重入锁?

「可重入锁」又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

  • 可重入的例子

    这里我们在method1就已经获取到TestSynchronized对应的monitor了,顺序执行再调用method2等方法的时候就不需要再重复的获取该锁了,直接在锁计数器上+1就行了。

     public class TestSynchronized {
     ​
         public static void main(String[] args) {
             TestSynchronized demo =  new TestSynchronized();
             demo.method1();
         }
     ​
         private synchronized void method1() {
             System.out.println(Thread.currentThread().getId() + ": method1()");
             method2();
         }
     ​
         private synchronized void method2() {
             System.out.println(Thread.currentThread().getId()+ ": method2()");
             method3();
         }
     ​
         private synchronized void method3() {
             System.out.println(Thread.currentThread().getId()+ ": method3()");
         }
     }
    

    我们着眼于锁的获取和释放以及锁计数器描述一下上面的这段程序的执行过程:

    • 执行monitorenter获取锁

      • monitor计数器=0,可获取锁)
      • 执行method1()方法,monitor计数器+1 -> 1 (获取到锁)
      • 执行method2()方法,monitor计数器+1 -> 2
      • 执行method3()方法,monitor计数器+1 -> 3
    • 执行monitorexit命令

      • method3()方法执行完,monitor计数器-1 -> 2
      • method2()方法执行完,monitor计数器-1 -> 1
      • method2()方法执行完,monitor计数器-1 -> 0 (释放了锁)
      • monitor计数器=0,锁被释放了)

    这就是Synchronized的重入性,即在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。

小结

本篇文章我们介绍了synchronized的使用,并且重点的解释了synchronized加锁和释放锁的原理,并且从monitor源码分析了线程获取对象锁的过程。

不过对于synchronized的内容还有锁优化没有说,锁优化的内容将在下一篇博客中介绍。

本章参考:

转载自:https://juejin.cn/post/7158825681898438692
评论
请登录