Java代码编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令,现在我们一起来探索下Java并发机制的底层实现原理,主要内容有volatile、synchronized、原子操作实现原理。
(1)volatile
当变量被定位volatile之后,它将具备两种特性:
可见性:保证此变量对所有线程的可见性,可见性指当一条线程修改了这个变量的值之后,新值对于其他线程来说是可以立即得知的。而普通变量的值在线程之间传递需要通过主内存来完成。
指令重排序:第二个语义就是禁止指令重排序。volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
问题一:volatile如何保证可见性?
即一个线程修改了值,另外一个线程可以立马可见,java内存模型对volatile变量的操作指定如何规则来保证可见性:
(1)每次使用变量前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量v所做的修改;(这个写回内存的操作会导致在其它CPU里缓存了该内存地址的数据无效)
(2)在工作内存中,每次修改变量都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量的修改;
问题二:volatile如何禁止指令重排序?
观察加入volatile关键字和没有加入volatile关键字时所生成的字节码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,在volatile修饰的变量进行赋值后,会添加lock操作(内存屏障),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
简单例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
新例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
指令重排序会导致语句2会在语句1之前执行,那么就可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。
(二)synchronized的实现原理
synchronized有三种使用形式,修饰同步方法(静态方法和实例方法)和修饰代码块。使用javap查看字节码,发现对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步代码块。JVM采用monitorenter、monitorexit两个指令来实现同步。synchronized使用场景如下图所示:
synchronized修饰同步方法原理:同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
synchronized修饰同步代码块原理:synchronized修饰同步代码块时,经过编译之后,会在同步代码块前后形成monitorenter和monitorexit两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中synchronized明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,那就根据synchronized修饰的实例方法和类方法,去取对应的对象实例或者Class对象作为锁对象。执行monitorenter指令时,需要尝试获取对象的锁,如果这个对象没有锁定,或者当前线程已经 拥有了那个对象的锁,把锁计数器加1,响应的在执行monitorexit指令时,会将锁计数器减1,当计数器为0时,锁就被释放,如果获取锁对象失败,那么当前线程就要阻塞等待,知道对象锁被另外一个线程释放位置。synchronized对象锁与对象的内存布局中(对象头、实例数据、对其填充)对象头有关系。
synchronized原理简单总结:同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。同步代码块通过monitorenter和monitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
当一个线程试图访问同步代码块时,它必须首先获取锁,退出或者抛出异常时必须释放锁,那么锁到底存放在那?锁里会存储什么信息那?
1.锁到底存放在那?
需要从Java对象的内存布局说起,Java对象内存布局分为三块区域:对象头、实例数据、对齐填充。
对象头:主要存储了2部分信息,第一部分是对象自身运行的数据,如hashcode,GC分代、锁状态标志、线程持有的锁、偏向线程ID等信息;
第二部分是类型指针,就是对象对它的类元数据指针,其实就是一个引用。虚拟机通过这个指针(引用)来确定对象是哪个类的实例。
实例数据(Instance Data):对象真正存储的有效信息,也就是程序代码中所写的各种类型的字段内容。
对齐填充(Padding):这个不是必然存在的。HotSpot虚拟机自动内存管理要求对象起始地址必须是8字节的整数倍,也就是对象大小必须是8字节的整数倍,对象实例数据部分没有对齐时,需要通过对齐填充来补齐。
因此,synchronized用的锁存放在Java对象头中。
2.锁里会存储什么信息?
为了减少获得锁和释放锁带来的性能消耗,虚拟机开发团队花费了大量的精力去实现各种锁优化技术。锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。要理解轻量级锁以及偏向锁的原理和运作过程,需要从JVM对象头了解,synchronized使用的锁对象是存储在Java对象头里。如果对象是数组类型使用3个字宽存储对象向头,如果是非数据类型用2字宽存储对象头,在32位虚拟机中,1字宽等于4字节,即32bit。Java对象头结构如下图所示:
MarkWord是实现偏向锁和轻量级锁的关键.其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等,以下是32位JVM的Mark Word默认存储结构如下图所示:
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间在运行期间,MarkWork里存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为存储以下四种数据,如下图所示:
(三)原子操作的实现原理
Java中可以通过CAS和锁来实现原子操作。
在java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。例如AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作,atomic操作的底层实现正是利用的CAS机制、原子类是通过 Unsafe 类中的 CAS 指令从硬件层面来实现线程安全的。
CAS机制:当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
CAS原理:CAS操作会调用Unsafe类里边的compareAndSwapInt()和compareAndSwapLong()等几个方法,虚拟机内部对这些方法做了特殊处理,即编译出来的结果对应一条平台相关的处理器CAS指令,即比较交换操作是一个原子操作。JVM中CAS操作利用了处理器提供的CMPXCHG指令实现。
CAS在并发机制中的重要性:
1.Java中Lock锁的同步状态存放在AQS中,使用一个int成员变量来表示同步状态,通过CAS来修改同步状态,从而保证并发安全性。
2.Java中原子操作类底层也是通过CAS来实现的。
3.sychronized锁机制也是基于CAS来实现的,synchronized使用的锁存放在Java对象头中,在运行期间对象头中的Mark Word里存储的数据会随着锁标志位的变化而变化,synchronized在获取锁的时候,使用CAS来修改Mark Word,从而来获取锁,即一个线程想进入同步块的时候需要使用CAS来获取锁,同时当它退出同步块的时候需要使用CAS来释放锁。从这个角度来看,synchronized实际同步锁也是基于CAS来实现。