并发问题的根源不在乎以下几个原因:可见性、原子性、有序性。
Java 常用 Synchronized、volatile 关键字来解决并发问题,在了解这两个关键字之前我们先来看看 Java 内存模型方便理解并发问题是如何产生的。
Java 内存模型(JMM)
硬件内存模型
目前基于高速缓存的存储交互很好的解决了 cpu 和内存等其他硬件之间的速度矛盾,多核情况下各个处理器(核)都要遵循一定的诸如 MSI、MESI 等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。
Java 内存模式(JMM)
Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各个平台下都能达到一致的并发效果。
主内存: Java 虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。
工作内存: Java 虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。
主内存、工作内存与 java 内存区域中的 java 堆、虚拟机栈、方法区并不是一个层次的内存划分。这两者是基本上是没有关系的,上文只是为了便于理解,做的类比
JMM 如何保证并发编程
Java 内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的
- 原子性(Automicity): 指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
- 可见性: 指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。(volatile 在 JMM 模型上实现 MESI 协议)
- 有序性: 指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致。(保证有序性的关键字有 volatile 和 synchronized,volatile 禁止了指令重排序,而 synchronized 则由“一个变量在同一时刻只能被一个线程对其进行 lock 操作”来保证。
指令重排: 可以保证串行语义一致,但是没有义务保证多线程间的语义一致,对于提高 CPU 处理性能是十分重要的
Happen-Before 规则: (不能重排的指令) 程序顺序原则、volatile 规则、锁规则...
Synchronized
Synchronized 除了原子性、可见性、有序性之外还有可重入性(一个线程可以重复申请锁)
Synchronized 的用法
- 作用在实例方法:监视器锁(monitor)便是对象实例
- 作用在静态方法:监视器锁(monitor)便是对象的 Class 实例,Class 数据存在方法区(永久代)
- 作用在代码块,监视器锁(monitor)便是括号起来的对象实例
public void test0(){
System.out.println("test0");
}
public synchronized void test1(){
System.out.println("test1");
}
public static synchronized void test2(){
System.out.println("test2");
}
public void test3() {
synchronized (this) {
System.out.println("test3");
}
}
查看 Synchronized 字节码
使用 javap -v
命令反编译 class
文件即可得到字节码文件,下面我们分别查看上述三种用法的字节码文件:
- 同步方法
作用在方法上可以看到在 flags
中多了一个 ACC_SYNCHRONIZED
的标志,这标志用来告诉 JVM
这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器 +1
,方法结束后计数器 -1
,如果获取失败就阻塞住,直到该锁被释放。
test1():
static test2():
- 同步代码块
从反编译的同步代码块可以看到同步块是由 monitorenter
指令进入,然后 monitorexit
释放锁,在执行 monitorenter
之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器 +1
。当执行 monitorexit
指令时,锁的计数器也会 -1
。当获取锁失败时会被阻塞,一直等待锁被释放。
但是为什么会有两个 monitorexit
呢?其实第二个 monitorexit
是来处理异常的,仔细看反编译的字节码,正常情况下第一个 monitorexit
之后会执行 goto
指令,而该指令转向的就是 22 行的 return
,也就是说正常情况下只会执行第一个 monitorexit
释放锁,然后返回。而如果在执行中发生了异常,第二个 monitorexit
就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
Synchronized 的底层实现
在理解底层实现之前先了解一下 Java 对象头和 Monitor,在 JVM 中,对象分为三部分存在的:对象头、实例数据、对齐填充
- 实例数据: 存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐
- 对其填充: 不是必须部分,由于虚拟机要求对象起始地址必须是 8 字节的整数倍,对齐填充仅仅是为了使字节对齐
- 对象头: 在 Hotshot 虚拟机的对象头主要由
Mark Word
、Class Metadata Address
组成。其中Mark Word
存储对象的hashCode
、锁信息
或分代年龄
或GC标志信息
,Class Metadata Address
是类型指针指向对象的类元数据
,JVM 通过该指针确定该对象是那个类的实例。
Mark Word 怎么存储锁信息
JDK6 之前只有两个状态:无锁
、有锁(重量级锁)
,而在 JDK6 之后对 synchronized
进行了优化,新增了两种状态,总共就是四个状态:无锁状态
、偏向锁
、轻量级锁
、重量级锁
,其中无锁就是一种状态了。考虑到存储成本,Mark Word 被设计成一个非固定的数据结构,它会根据对象的状态复用自己的存储空间,它可能随着运行状态变成下面 4 中数据:
最后两位存储锁的标志位,01 是初始状态,未加锁。偏向锁存储的是当前占用此对象的线程 ID;而轻量级则存储指向线程栈中锁记录的指针
Monitor 对象
每一个锁都对应一个 monitor对象
,在 HotSpot
虚拟机中它是由 ObjectMonitor
实现的(C++ 实现)。每个对象都存在着一个 monitor
与之关联,对象与其 monitor
之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
ObjectMonitor() {
_header = NULL;
_count = 0; //锁计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于