synchronized详解
并发编程中的三大问题
- 原子性问题(Atomicity):
- 定义: 保证一个操作是不可中断的,要么全部执行成功,要么全部不执行。
- 问题: 当多个线程同时执行一组操作时,可能出现一部分操作已经执行而另一部分没有执行的情况,导致数据不一致性。
- 可见性问题(Visibility):
- 定义: 一个线程对共享变量的修改能够及时被其他线程看到。
- 问题: 当一个线程修改了共享变量的值,其他线程可能无法立即感知到这个变化,从而导致数据的不一致性。
- 有序性问题(Ordering):
- 定义: 确保程序执行的顺序与代码的书写顺序一致。
- 问题: 由于编译器的优化或硬件的乱序执行,代码的执行顺序可能与预期不一致,导致意外的结果。
JMM(Java Memory Modle)
Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。它是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。
- 主内存:是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
- 工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
- JMM是一种抽象的逻辑概念,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉
JMM中主内存与工作内存之间的数据交互过程
Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
分别是lock、read、load、use、assign、store、write、unlock
1.如果对一个变量执行lock操作,将会清空工作内存中此变量的值
2.对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
synchronized与三大特性
synchronized可以保证原子性、有序性、可见性,其中有序性和可见性是通过JMM规范的八个命令中的lock和unlock命令保证的
- 原子性(Atomicity):
synchronized
保证了代码块或方法中的所有操作要么全部执行成功,要么全部失败,不会被其他线程中断。当一个线程获得锁时,它将独占锁,其他线程必须等待释放锁后才能继续执行。
- 可见性(Visibility):
- 当一个线程获得锁进入
synchronized
代码块时,它会清空工作内存中的共享变量副本,强制从主内存中重新读取共享变量的值。同样,线程在释放锁的时候会将修改后的值刷新回主内存,这样其他线程能够感知到这些变化。这确保了对共享变量的修改对其他线程可见。
- 当一个线程获得锁进入
- 有序性(Ordering):
- as-if-serial:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。也就是逻辑上有先后关系的代码不会被重排序,例如a=1,b=2,c=a+b可能被重排序为b=2,a=1,c=a+b,但不可能被重排序为a=1,c=a+b,b=2
synchronized
保证了线程进入和退出临界区的有序性。即,一个线程在释放锁之前,必须把修改过的变量刷新到主内存,而获取锁的线程要求从主内存中读取变量的最新值。这就确保了线程之间的有序性,防止了乱序执行。
特性
可重入锁
一个线程在持有锁的情况下,可以再次获取相同的锁,而不会造成死锁。这是通过为每个锁关联一个持有者线程和一个计数器来实现的。当一个线程第一次获取锁时,计数器为1,每次递归获取锁,计数器递增。只有当计数器降为零时,锁才会被释放。这确保了线程可以多次进入同一个 synchronized
代码块,而不会发生死锁。可重入锁是一种提高程序灵活性和避免死锁的设计。
不可中断
不可中断:一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
原理
monitorenter
- 每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下︰
- 获取当前对象对应的monitor的所有权的过程:
- 若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner (所有者)
- 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
- 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
- synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量:
- owner:拥有这把锁的线程
- recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
monitorexit
- 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权(owner)的线程。
- 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权monitorexit释放锁。
- monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit,因此synchornized出现异常时会释放锁
对象头的结构
在 HotSpot JVM 中,对象头包括两部分信息,分别是 Mark Word 和 Klass Pointer
- Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode ) 、GC分代年龄、锁状态标志线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。
- Klass Pointer用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
锁升级
- 偏向锁(Biased Locking):
- 初始时,一个对象的头部信息会包含偏向锁标记。当某个线程访问这个对象并获取锁时,会将该线程的ID记录在对象头中,并将偏向锁标记置为1。之后,如果同一个线程再次访问该对象,无需竞争,直接获取锁。这提高了同一线程多次获取锁的效率。
- 轻量级锁(Lightweight Locking):
- 当有多个线程竞争同一把锁时,偏向锁就不再合适。此时,锁会升级为轻量级锁。在轻量级锁的情况下,当前获取到锁的线程将对象的Mark Word复制到栈帧中的Lock Recod中,Mark Word更新为指向Lock Record的指针。其他线程在尝试获取锁时会将对象头的 Mark Word 复制到线程的栈帧中,并进行自旋操作,而不是直接阻塞线程。如果自旋失败(10次以上仍未获取),锁升级为重量级锁。
- 重量级锁(Heavyweight Locking):
- 当轻量级锁自旋失败,多个线程竞争同一把锁的时候,锁会升级为重量级锁。在这种情况下,JVM 使用操作系统提供的互斥量来实现锁。此时,如果一个线程获取了锁,其他线程必须阻塞等待。重量级锁是通过对象头关联的monitor(见补充)实现的。
优化
- 锁消除:在synchronized修饰的代码中,如果不存在操作临界资源的情况,编译时JIT触发锁消除,相当于无锁
- 锁膨胀:如果在一个循环中,频繁的获取和释放做资源,这样带来的消耗很大,锁膨胀就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗
- 锁升级:synchronized在JDK1.6之前,获取不到锁,立即挂起当前线程,所synchronized性能比较差,之后做了锁升级的优化,即无锁—>偏向锁—>轻量级锁—>重量级锁;有的观点认为锁只能升级,不能降级,也有说锁降级确实是会发生的,当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
补充
java中对象的结构
java对象由三部分组成:对象头,实例数据,对齐填充
- 对象头:由 Mark Word 和 Klass Pointer组成
- 实例数据:就是类中定义的成员变量
- 对齐填充:并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
monitor
- 每一个Java对象都
可以
与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。 - monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉VM需要为我们的某个对象创建关联的monitor对象。
- monitor的重要成员变量
- _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的。
- _CXq∶竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈)。
- _EntryList : _cxq队列中有资格成为候选资源的线程会被移动到该队列中。
- _WaitSet :因为调用wait方法而被阻塞的线程会被放在该队列中。
- monitor是重量级锁:
- ObjectMonitor(hotspot的monitor实现)的函数调用中会涉及到Atomic::cmpxchg_ptr,Atomic:.inc_ptr等内核函数,执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个过程中存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized在Java语言中是一个重量级(Heavyweight)的操作。
安全点(safe point)
安全点是程序执行的一个状态,JVM 在这个状态下,能够确保所有线程都在安全位置,不会出现对对象引用的修改。在安全点上,JVM 可以停止所有线程,进行垃圾回收、线程栈的修改等操作,而不会导致数据不一致或错误的状态。在安全点上,线程不会在执行关键的、可能导致数据不一致的代码。
安全点通常发生在一些特定的位置,包括:
- 方法调用:
- 在方法调用的地方,JVM 可以方便地确保所有线程都在一个安全的状态。这可以是方法的入口或返回点。
- 循环跳跃:
- 在循环的迭代点,例如 for 循环的迭代点,也是安全点。这是因为在循环迭代点,线程通常都在相对较安全的位置。
- 异常抛出:
- 在抛出异常的地方,例如在
throw
语句附近,也是一个潜在的安全点。
- 在抛出异常的地方,例如在
- 代码生成器插入的安全点:
- 代码生成器在生成机器码时,会在适当的位置插入安全点,以确保在执行这些机器码时,所有线程都在一个安全的状态。。在某些情况下,为了减少安全点的频率,JVM 可能会选择较少的插入安全点,从而提高程序的运行效率。
synchronized与Lock的区别
- synchronized是关键字,而Lock是一个接口。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
总结
- synchornized是可重入锁,不可中断锁
- 可以保证原子性,有序性和可见性(JMM中定义的8种原子操作)
- 原理是java对象头(偏向锁、轻量级锁)以及monitor(重量级锁)
- jdk6后提供了锁优化:锁消除、锁膨胀、锁升级