绕不过的并发编程——synchronized 原理
简单介绍
并发编程的三大问题
什么是并发编程的三大问题?为什么有这些问题?具体的例子呢?
在我的博客:从零开始的 JVM 学习--Java 内存模型(JMM)中有提到并发编程的三个基本问题:
可见性
什么是可见性?
「可见性」是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
为什么有可见性问题?从计算机底层的角度来看对于如今多核处理器,每个 CPU 有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU 缓存和内存的数据不容易保证一致。从 Java 内存模型的角度来看 JMM 规定了所有变量都存储在内存中,每条线程有自己的工作内存。线程的工作内存中保存的是该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作必须在工作内存中进行,不能直接读写主内存。 线程对变量的副本的写操作,不会马上同步到主内存。不同线程之间也无法直接访问对方工作内存中的变量 ,线程间变量的传递需要自己的工作内存和主存之前进行数据同步。
原子性
什么是原子性?
「原子性」是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
为什么有原子性问题?从计算机底层的角度来看线程是 CPU 调度的基本单位。CPU 会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去 CPU 的使用权,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程执行同一段代码,也就是原子性问题。从 Java 内存模型角度来看 JMM 实际上保证了对基本数据类型的变量的读取和赋值操作都是原子性操作的:java 复制代码 a = true; //原子性 a = 5; //原子性但是我们实际写代码经常需要用的操作比如:java 复制代码 a = b; //非原子性,分两步完成,第一步加载 b 的值,第二步将 b 赋值给 a a = b + 1; //非原子性,分三步完成 a ++; //非原子性,分三步完成会导致原子性问题。
原子性问题例子
模拟场景:
10 个线程将 a 加到 10000(保证可见性)
测试代码:
java 复制代码
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);; }
运行结果:
java 复制代码
9905
解释:
由于 a++总共是三个操作,首先从工作内存中读取变量副本到操作数栈中,然后再进行自增运算,最后写回到线程工作内存中。如果 3 个操作的空挡被别的线程干扰,则很可能丢失自增机会。例如下面场景:
java 复制代码
线程 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
(保证可见性情况下,变量被修改完成确实是线程之间同步可见的,但是修改过程不原子的话就读的就可能是脏数据了)
有序性
什么是有序性?
「有序性」是指程序执行的顺序按照代码先后执行。
为什么有有序性问题?指令重排序如果没有「指令重排序」的话就不会有有序性问题了。什么是指令重排序?编译器和处理器在不影响代码单线程执行结果的前提下,对源代码的指令进行重新排序执行。这种重排序执行是一种优化手段,目的是为了处理器内部的运算单元能尽量被充分利用,提升程序的整体运行效率。重排序分成以下几种
编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
有序性问题例子
指令重排只保证单线程执行下的正确性,在多线程环境下,指令重排就会带来一定问题。
java 复制代码
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=true。started 变成 true 后另一个线程立马执行 checkValue 方法,这个时候 value 就是 1,于是出现有序性问题!
关于有序性问题还有一个知识点就是 happens-before 原则,不过和 volatile 关键字的关联比较深,于是会放到 volatile 章节再谈。
synchronized 详解
synchronized 作用
synchronized 可以解决并发编程的三大问题
synchronized 关键字可以解决可见性,原子性,有序性问题:
可见性问题解决
「可见性」是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
如何解决?
synchronized 和 volatile 都具有可见性,其中 synchronized 对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。
简单来说就是确保了下一个线程获得锁前,本线程的修改已经同步到了内存。
原子性问题解决
「原子性」是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
如何解决?
被 synchronized 修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
简单来说就是一次只有一个线程获得锁,所以一个线程的操作可以完整的执行(不受干扰)。
有序性问题解决
「有序性」是指程序执行的顺序按照代码先后执行。
如何解决?
synchronized 和 volatile 都具有有序性,Java 允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。
synchronized 保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
synchronized 使用
synchronized 关键字最主要的三种使用方式
修饰实例方法
用于给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
java 复制代码
synchronized void method(){ // ... }
修饰静态方法
用于给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
因为静态成员不属于任何一个实例对象,是类成员。
所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,不会发生互斥现象。
因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
java 复制代码
synchronized void static method(){ // ... }
修饰代码块
指定加锁对象,用于给指定对象/类加锁。synchronized(this|object) 表示进入同步代码块前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
java 复制代码
sychronized(this){ // ... }
总结一下:
synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
synchronized 关键字加到实例方法上是给对象实例上锁。
加锁和释放锁的原理
synchronized 实现原理
从底层指令的角度来看
synchronized 的功能是基于 monitorenter 和 monitorexit 指令实现的。
monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。
任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。例子 java 复制代码 public class Hello { public static void main(String[] args) { synchronized (Hello.class){ System.out.println("Hello"); } } }使用 javap -c Hello 可以查看编译之后的具体信息。java 复制代码 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 }可以看到同步块的入口和出口分别有 monitorenter 和 monitorexit。
从面向对象角度来看
JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。
(具体实现就是上面讲的,在编译之后在执行同步方法前加入一个 monitorenter 指令,在退出方法和异常处插入 monitorexit 的指令)
其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。
而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitorexit 之后才能尝试继续获取锁。
该图可以看出,任意线程对 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++实现,源码如下:
cpp 复制代码
// 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,并且随着重入的次数,会一直累加。这把锁已经被别的线程获取了,则需等待锁释放。
等待获取锁 以及 获取锁出队 流程
线程「等待获取锁」以及「获取锁出队」的示意图如下图所示:
多个线程想要获取锁的时候,首先都会进入(enter)到_EntryList 队列
其中一个线程获取(acquire)到对象的 monitor 后,_owner 变量会被设置为当前线程,并且 monitor 维护的计数器加 1。
如果当前线程执行完逻辑并退出后(release and exit),monitor 中_owner 变量就会清空并且计数器减 1,这样就能让其他线程能够竞争到 monitor。
另外,如果调用了 wait()方法后,当前线程就会释放锁(release)并且进入到_WaitSet 中等待被唤醒。如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到 monitor。
可重入锁
什么是可重入?
若一个程序或子程序可以「在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错」,则称其为「可重入」(reentrant 或 re-entrant)的。
即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。
与多线程并发执行的线程安全不同,「可重入」强调对单个线程执行时重新进入同一个子程序仍然是安全的。
什么是可重入锁?
「可重入锁」又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
可重入的例子
这里我们在 method1 就已经获取到 TestSynchronized 对应的 monitor 了,顺序执行再调用 method2 等方法的时候就不需要再重复的获取该锁了,直接在锁计数器上+1 就行了。
java 复制代码
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 -> 2method2()方法执行完,monitor 计数器-1 -> 1method2()方法执行完,monitor 计数器-1 -> 0 (释放了锁)(monitor 计数器=0,锁被释放了)
这就是 Synchronized 的重入性,即在同一锁程中,每个对象拥有一个 monitor 计数器,当线程获取该对象锁后,monitor 计数器就会加一,释放锁后就会将 monitor 计数器减一,线程不需要再次获取同一把锁。
小结
本篇文章我们介绍了 synchronized 的使用,并且重点的解释了 synchronized 加锁和释放锁的原理,并且从 monitor 源码分析了线程获取对象锁的过程。
不过对于 synchronized 的内容还有锁优化没有说,锁优化的内容将在下一篇博客中介绍。
评论