volatile&synchronized
synchronized在并发编程中我们接触的比较多,volatile是一个比synchronized更加轻量级的可以解决部分并发编程中线程可见性问题的一个关键字。Java中的AQS就是使用volatile的一个经典模型,同时Java并发包下的Lock又是基于AQS实现,所以了解volatile和synchronized对我们了解Java并发问题有很大的帮助。
下面有一段代码,当使用多个线程进行并发操作的时候,最后结果都会小于200000,为什么会存在这样的情况?因为Java在并发编程中,对线程共享变量的操作,会存在线程可见性问题。想要解决和弄清楚这些问题,我们首先需要了解Java的基本内存模型,同时线程间又是如何通信的。
public class VolatileDemo {
public static int race = 0;
private static final int THREAD_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
race++;
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
1、Java内存模型
JVM运行时数据区如下图所示:
主要分为两大部分:
- 线程独占:每个线程都会有它独立的空间,随线程生命周期创建和销毁
- 线程共享:所有的线程都能够访问到这块内存数据,随JVM或者GC创建和销毁
1.1、方法区
方法去是JVM用来存储加载的类信息、常量、静态变量,编译后的代码等数据的一个内存区域。但是这只是Java虚拟机规范当中的一个逻辑区划,不同的虚拟机会有不同的实现。
例如:HotSpot在Java7中方法区放在永久代,Java8放在元数据空间,并通过GC对这个区域进行管理。
1.2、堆内存
堆内存用于JVM启动时存放对象实例,堆内存中又可以分为以下两个部分:
- 老年代:年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁(譬如可能几个小时一次)。
- 新生代:Eden、From Survivor、To Survivor,年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。
在堆内存中,会出现OutOfMemoryError。
1.3、虚拟机栈
每个线程在虚拟机栈都有一个私有的空间。线程栈由多个栈帧组成,同时一个方法对应一个栈帧,如果一个线程会执行多个方法,那么一个线程就会由多个栈帧。
栈帧的内容包括:局部变量表、操作数栈、动态链接、方法返回地址、附加信息等。
栈内存默认最大值为1M,超出这个大小将会抛出StackOverflowError。
1.4、本地方法栈
跟虚拟机栈类似,但是执行的方法有区别,虚拟机栈执行Java方法,本地方法栈执行native方法,超出大小之后一样会抛出StackOverflowError。
1.5、程序计数器
记录当前线程执行字节码位置,存储的是字节码指令地址,如果执行native方法,则计数器值为空。
每个线程都在程序计数器空间有自己的一个私有空间,占用内存很小。
为什么要存在程序技术器这个内存空间?因为cpu会在多个线程之间来回切换执行,所以如果切换到一个线程的时候,需要通过程序计数器来恢复正确的执行位置。
2、volatile
2.1、volatile的特性
- 可见性:被volatile修饰的变量会对其他的线程保持可见性;就是当我们修改一个volatile的变量时,其他的线程可以立刻感知到这个变量已经被修改,同时会将当前线程中的该变量的副本(当前线程的工作内存)作废,然后到主内存中去获取该变量的最新值。
- 原子性:对任意单个volatile变量的读写操作具有原子性,但是对于i++这种复合操作,不具有原子性。
- 禁止指令重排序优化:在程序真正运行的时候,为了保证CPU的性能,基本上都会做指令重排,但是如果声明一个变量为volatile类型的时候,就相当于插入了一个内存屏障,不允许将后面的屏障后的操作重排到屏障之前。
对于volatile相对于synchronized而言,不会引起线程上下文的切换和调度,所以在合适的场景下使用,效率相对于synchronized要高。
2.2、volatile的使用
对于设计模式中的单例模式,大家应该都不陌生,其中双重锁单例模式如下:
package com.xiaohuihui.design.singleton;
/**
* @Desription: 双重锁检查机制
* 1、实例化线程安全
* 2、懒加载,节约资源
* 3、对于JVM而言,它执行的是一个个Java指令。在Java指令中创建对象和赋值操作是分开进行的,
* 也就是说instance = new DoubleCheckLockMode();语句是分两步执行的。
* 但是JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的DoubleCheckLockMode实例分配空间,
* 然后直接赋值给instance成员,然后再去初始化这个DoubleCheckLockMode实例。
* (即先赋值指向了内存地址,再初始化)这样就使出错成为了可能
* <p>
* 链接:https://blog.csdn.net/gangjindianzi/article/details/78689713
* @Author: yangchenhui
*/
public class DoubleCheckLockMode {
private static DoubleCheckLockMode instance;
private DoubleCheckLockMode() {
}
public static DoubleCheckLockMode getInstance() {
// 先判断该实例是否已经被实例化,没有实例化才需要进行实例化
if (null == instance) {
// 实例化代码写到同步代码块中,只有一个线程可以实例化对象
synchronized (DoubleCheckLockMode.class) {
// 有多个线程同时进来,先判断是否已经实例化,保证只有一个线程可以实例化对象
if (null == instance) {
instance = new DoubleCheckLockMode();
}
}
}
return instance;
}
}
对于双重锁机制保证单例,有可能会出现问题,就是在Java中创建一个对象,可以由下面三步组成:
1、分配对象的内存空间:memory = allocate();
2、初始化对象:ctorInstance(memory);
3、设置instance指向分配的内存地址:instance = memory;
其中第2步和第3步是可以进行指令重排的,但是使用null==instance来判断的时候只是判断的内存地址,此时内存地址已经开辟,但是却没有初始化实例,可以由下表格进行分析:
此时线程B将会使用一个还未实例化的对象,自然就会出现问题,那么如何来解决这种问题呢?volatile就派上了用场,将instance声明为volatile类型即可,这个时候步骤2和步骤3就不会发生指令重排。
这只是volatile的使用点之一,最重要的实现就是利用volatile的可见性特性,实现Java并发包下的AQS和Lock。
3、synchronized
synchronized基于Java对象监视器(管程)实现,Java中的每一个对象都拥有一个监视器,synchronized也是最常见的线程通信方式。通过加锁使得资源只能够被一个线程所访问,当执行完毕后等待的线程就会拿到锁,获取到上一个线程修改的东西,也就实现了线程间的通信。以32位的JVM为例,Mark Word的默认存储结构如下:
3.1、synchronized的特性
在Java并发编程中,synchronized可以有下面的一些表现形式:
1、普通同步方法:锁为当前实例对象;
2、静态同步方法:锁为当前类的class对象;
3、对于同步方法块:锁为synchronized(锁对象)
想要了解synchronized的实现原理,我们首先需要了解Java的对象头里面都存储了哪些信息。如下图所示(来自《Java并发编程的艺术》):
对象的相关锁信息就存储在Mark Word中,在jdk1.6之后,为了减少获得锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁。Mark Word也会随着线程资源的争抢可进行改变,从而实现锁的升级。
GC标记表示这个对象需要被JVM回收。
3.2、锁升级
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁只能升级,不能降级。轻量级锁只使用cas操作锁标志位,但是重量级锁会操作监视器,当cas自旋一定次数之后,轻量级锁会膨胀为重量级锁,同时在监视器中存储等待锁的线程集合。
1、偏向锁
在程序运执行同步代码的时候,并不是总是存在锁竞争的情况,如果在这个时候频繁的去获取锁和释放锁,会造成不必要的性能开销。为了降低线程获取锁的代价,引入了偏向锁。偏向锁使用“是否是偏向锁” + Mark Word中存储的线程id实现,当某一个线程过来争抢锁的时候,首先会判断当前锁是否为偏向锁,如果是偏向锁,则使用cas机制修改Mark Word信息,如果修改成功,则表示已经获取到锁,可以继续往下执行流程。
当一个Java对象被创建的时候,此时处于无锁状态,当有线程获取锁的时候,会从无锁状态升级为偏向锁状态。偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。并且偏向锁只有在第一次获取的时候有用,如果出现过锁竞争,偏向锁就没有用了,因为锁不会降级。下图为偏向锁的获取和撤销流程图:
如果当线程1在使用锁的过程中,如果线程2也尝试cas修改对象头信息,那么线程2将会失败。此时锁将会发生膨胀,进入到轻量级锁阶段。
2、轻量级锁
在轻量级锁阶段,未获取到锁的线程会采用cas自旋的方式来获取锁。从偏向锁升级到轻量级锁的过程中,Mark Word当中的信息也会发生变化,从存储线程id转变为存储指向栈的指针。但是如果一直进行cas操作,但是又没有获取到锁,很显然非常耗费CPU的资源,所以当cas操作一定次数之后么,轻量级锁将会膨胀成为重量级锁。此时将会操作对象监视器,将等待的线程加入到对象监视器的集合当中,等待释放锁的唤醒操作。
升级到重量级锁之后,锁状态不会降级,因为升级会重量级锁的目的就是为了防止cas操作消耗过多的cpu资源,降级到轻量级锁之后又会进行cas自旋操作。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3、重量级锁
当cas自旋一定次数之后,会升级为重量级锁,这个次数可以通过参数配置,升级为重量级锁之后,线程阻塞。
重量级锁操作对象监视器,而轻量级锁操作Mark Word中的标记位。
4、volatile与synchronized的区别
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
- volatile仅能实现变量的修改可见性,不能保证原子性(例如i++);而synchronized则可以保证变量的修改可见性和原子性
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化