本文由 Oo鲁毅oO 首发于 掘金,下方为原文链接
深入浅出并发编程底层原理
1.Java内存模型——底层原理
1.1 什么是底层原理
Java程序编译到运行需要经过将.java后缀的文件通过javac命令编译成.class文件(此时与平台无关),然后将对应的.class文件转化成机器码并执行,但是由于不同平台的JVM会带来不同的“翻译”,所以我们在Java层写的各种Lock,其实最终依赖的是JVM的具体实现和CPU指令,才能帮助我们达到线程安全的效果。
2 三兄弟:JVM内存结构、Java内存模型、Java对象模型
2.1 JVM内存结构,和Java虚拟机的运行时数据区有关
- 堆:堆是内存结构中最大的一块区域,线程共享并且动态分配内存,当创建了一个对象就会在堆上分配内存,当堆满了之后会触发GC进行垃圾回收。
- 方法区:方法区是线程共享的,方法区用于存储类信息、常量以及静态变量。
- Java栈(虚拟机栈):虚拟机栈是线程私有的,它的内存是不可变的,也就是说在编译时就已经确定了的,虚拟机栈用于存储局部变量表、操作数栈、动态链接和方法出口。
- 本地方法栈:本地方法栈的作用于虚拟机栈类似,区别在于一个服务于Java方法一个服务于native方法
- 程序计数器:程序计数器是线程私有的,它占用的空间非常小也是唯一一个不存在OOM问题的区域,主要用于记录程序执行的行号数。
2.2 Java内存模型,和Java的并发编程有关
下面介绍
2.3 Java对象模型,和Java对象在虚拟机中的表现形式有关
Java对象模型是Java对象自身的存储模型,JVM会给一个类创建一个instanceKlass,保存在方法区中,用来在JVM层表示该Java类。
在使用new指令创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据
3.JMM(Java Memory Model)
3.1 为什么需要JMM
C/C++语言它们不存在内存模型的概念,它们依赖于处理器,不同的处理器处理的结果不同,也就无法保证并发安全。所以此时需要一个标准,让多线程的运行结果可预期。
3.2 什么是JMM
JMM是一组规范,要求JVM依照规范来实现,从而让我们更好的开发多线程程序。如果没有了JMM规范,那么不同的虚拟机可能会进行不同的重排序,这样就会导致不同的虚拟机上运行的结果不同,这也就引法了问题。
JMM除了是规范还是工具类和关键字的原理,我们常见的
volatile
、synchronized
以及Lock
等的原理都是JMM。如果没有JMM,那就需要我们自己指定什么时候需要内存栅栏(工作内存与主内存之间的拷贝同步)等,这样就很麻烦,因为有了JMM,所以我们只需要使用关键字就可以开发并发程序了。
4.JMM之重排序
第一种执行情况
/**
* 演示重排序的现象
* “直到达到某个条件才停止”,测试小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
one.start();
two.start();
one.join();
two.join();
System.out.println("x = " + x + ", y = " + y);
}
}
第二种执行情况
/**
* 演示重排序的现象
* “直到达到某个条件才停止”,测试小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
two.start();
one.start();
one.join();
two.join();
System.out.println("x = " + x + ", y = " + y);
}
}
第三种执行情况
/**
* 演示重排序的现象
* “直到达到某个条件才停止”,测试小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await(); //进行等待
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await(); //进行等待
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
latch.countDown(); //统一开始执行
one.join();
two.join();
System.out.println("x = " + x + ", y = " + y);
}
}
对第三种情况的优化
/**
* 演示重排序的现象
* “直到达到某个条件才停止”,测试小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0; //计数
for (; ; ) {
i++;
x = 0; //清零操作
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
latch.countDown();
one.join();
two.join();
String result = "第" + i + "次 (" + x + ", " + y + ")";
if (x == 1 && y == 1) {
System.out.println(result);
return; //满足条件后退出循环
} else {
System.out.println(result);
}
}
}
}
总结:代码的执行顺序决定了执行的结果
4.1 眼见为实的重排序
只需将上面的结束条件改为x == 0 && y == 0
即可
- 为什么会出现x=0, y=0 ?
出现这种情况是因为重排序发生了,代码的执行顺序有可能为
y = a;
a = 1;
x = b;
b = 1;
- 什么是重排序
线程1中代码的执行顺序与Java代码不一致,代码的执行顺序并不是按照指令执行的,它们的执行顺序被改变了,这就是重排序。
4.2 重排序的好处与发生的时机
对比下图可以发现如果进行重排序可以减少关于变量a
的执行指令,如果在程序中个存在大量的类似情况,也就提高了处理速度。
4.3 重排序的3种情况
- 编译器优化:包括JVM,JIT编译器等
比如存在变量a和b,如果将对a的操作连续执行效率更高的话,就可能发生重排序来提高执行效率。
- CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排
CPU重排和编译器重排类似,就算编译器不重排CPU也会进行重排,它们都是打乱执行顺序达到优化的目的。
- 内存的“重排序”:线程A的修改线程B却看不到,引出可见性问题
内存中的重排序并非真正的重排序,因为内存中有缓存的存在,在JMM中表现为本地内存和主内存,如果线程1修改了变量a的值还没有来得及写入到主存,此时线程2由于可见性的原因无法知道线程1对变量进行了修改,所以会使程序表现出乱序行为。
5.JMM之可见性
5.1 什么是可见性
当一个线程执行写操作时,另外一个线程无法看见此时被更改的值。就像下图所示当线程1从主存中读取变量x,并将x的值设置为1,但是此时线程1并没有将x的值写回主存,所以线程2就无法得知x的值已经改变了。
演示代码
/**
* 演示可见性带来的问题
*/
public class FieldVisibility {
int a = 1;
int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
System.out.println("b = " + b + "; a = " + a);
}
private void change() {
a = 3;
b = a;
}
}
四种情况
a = 3; b = 3;
a = 1; b = 2;
a = 3; b = 2;
a = 1; b = 3; //发生可见性问题
5.2 解决可见性问题——使用volatile
/**
* 演示可见性带来的问题
*/
public class FieldVisibility {
//解决可见性问题
volatile int a = 1;
volatile int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
System.out.println("b = " + b + "; a = " + a);
}
private void change() {
a = 3;
b = a;
}
}
volatile怎么解决可见性问题
当线程1读取到x并将值更新为1后会刷回主存,当线程2再次读取x时就会从主存中加载,这样就不会引发可见性的问题。
5.3 为什么会出现可见性问题
引发可见性问题的原因是因为读取数据时需要一层一层对数据进行缓存,如果直接从RAM中读取数据的话,这样会大量降低读取速度,这也是需要JMM的原因。
5.4 JMM的抽象——主内存和本地内存
Java作为高级语言,屏蔽了这些底层细节,用JMM定义了这些读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是JMM抽象了主内存和本地内存的概念。
这里说的本地内存并不是真正的为每个线程分配一块内存,而是JMM的抽象,是对寄存器、一级缓存、二级缓存的抽象。
主内存和本地内存的关系
JMM有以下规定
- 所有的变量都存储在主内存中,每个线程中有独立的工作内存,工作内存中的变量是主内存中的拷贝。
- 线程不能直接操作主内存中的变量,只能通过自己的工作内存读取主内存中的变量再写回去。
- 主内存是线程共享的,但是工作内存不是,如果线程之间通信必须通过主内存进行中转。
总结:线程操作数据必须从主内存中读取数据,然后在自己的工作内存中进行操作,操作完成后再写回主内存,因为读写需要时间所以就会引发可见性的问题
6.Happens-Before规则有哪些?
- 单线程原则
在单线程情况下,后面的语句一定能看到前面的语句做了什么
- 锁操作(synchronized和Lock)
加锁之后能看到解锁之前的全部操作
- volatile变量
被volatile
修饰的变量只要执行了写操作,就一定会被读取到
- 线程启动
调用start()
方法可以让子线程中所有语句看到启动之前的结果
- 线程join
join()
后的语句能看到等待之前的所有操作
- 传递性
比如第一行代码运行后第二行会看到,第二行运行后第三行会看到,从中可以推断出第一行代码运行完第三行就会看到。
- 中断
如果一个线程被interrupt()
时,那么isInterrupt()
或者InterruptException
一定能看到。
- 构造方法
对象构造方法的最后一行语句happens-before于finalize()
的第一行语句
- 工具类的Happens-Before原则
- 线程安全的容器get一定能看到在此之前的put操作
- CountDownLatch
- CyclicBarrier
- Semaphore
- Future
- 线程池
7.volatile关键字
7.1 什么是volatile
volatile
是一种同步机制,相对synchronized
和Lock
更轻量,不会带来上下文切换等重大开销。如果一个变量被volatile
修饰,那么JVM就知道这个变量可能会被并发修改。虽然volatile
的开销小,但是它的能力也小,相对于synchronized
来说volatile
无法保证原子性。
7.2 volatile的使用场景
- 不适用于组合操作
/**
* 不适用volatile的场景
*/
public class NoVolatile implements Runnable {
volatile int a;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
realA.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
NoVolatile noVolatile = new NoVolatile();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.a);
System.out.println(noVolatile.realA.get());
}
}
- 适用于直接赋值操作
/**
* volatile的适用场景
*/
public class UseVolatile implements Runnable {
volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
setDone();
realA.incrementAndGet();
}
}
private void setDone() {
done = true;
}
public static void main(String[] args) throws InterruptedException {
UseVolatile noVolatile = new UseVolatile();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.done);
System.out.println(noVolatile.realA.get());
}
}
注意:赋值操作本来是原子操作,所以对volatile
修饰的变量进行赋值可以保证线程安全,但是如果不是直接赋值则无法保证,请看下面的例子
/**
* 不适用volatile的场景
*/
public class NoVolatile2 implements Runnable {
volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
flipDone();
realA.incrementAndGet();
}
}
private void flipDone() {
done = !done;
}
public static void main(String[] args) throws InterruptedException {
NoVolatile2 noVolatile = new NoVolatile2();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.done);
System.out.println(noVolatile.realA.get());
}
}
上来初始值为false
,所以执行偶数次结果应该为false
,可是执行了20000次之后结果却是true
,从中便可以看出volatile
在此情况下不适用
- 使用情况2:作为刷新变量前的触发器
这里可以使用前面的例子
/**
* 演示可见性带来的问题
*/
public class FieldVisibility {
int a = 1;
int abc = 1;
int abcd = 1;
volatile int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
if (b == 0) {
System.out.println("b = " + b + "; a = " + a);
}
}
private void change() {
abc = 7;
abcd = 70;
a = 3;
b = 0;
}
}
在这里b==0
作为触发的条件,因为在change()
方法中最后一句将b设置为0,所以依照happens-before原则在b=0以前的操作都是可见的,从而达到了触发器的作用
7.3 volatile的两点作用
- 可见性:读一个
volatile
修饰的变量需要使本地缓存失效,然后从主存中读取新值,写一个volatile
变量后会立即刷回主存 - 禁止指令重排优化:解决单例双重锁乱序问题
7.4 volatile和synchronized的关系
volatile
是轻量级的synchronized
,当在多线程环境下只做赋值操作时可以使用volatile
代替synchronized
,因为赋值操作自身保证原子性,而使用volatile
又能保证可见性,所以可以实现线程安全。
7.5 volatile总结
- voaltile修饰符适用于以下场景:当某个属性被线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如
boolean flag;
,或者作为触发器实现轻量级同步。 - volatile的读写都输无锁操作,它不能替代
synchronized
是因为它无法提供原子性和互斥性,因为无锁,它也不会在获取锁和释放锁上有开销,所以说它是低成本的。 - volatile只能用于修饰某个属性,被volatile修饰的属性不会被重排序。
- volatile提供可见性,任何线程对其进行修改后立刻就会对其他线程可见,volatile属性不会被线程线程缓存,必须从主存中读取。
- volatile提供了happens-before保证
- volatile可以保证long和double的赋值操作都是原子的
7.6 能保证可见性的措施
除了volatile可以保证可见性之外,synchronized、Lock、并发集合、Thread.join()和Thread。start()都可以保证可见性(具体看happens-before原则)。
7.7 对synchronized理解的升华
- synchronized不仅可以保证原子性,还能保证可见性
- synchronized不仅让被保护的代码安全,而且还“近朱者赤”(具体看happens-before原则)
8.JMM之原子性
8.1 什么是原子性
一系列操作要么全部成功,要么全部失败,不会出现只执行一半的情况,是不可分割的。
8.2 Java中原子操作有哪些
- 除了long和double之外的基本类型的赋值操作。
- 所有引用的赋值操作
- java.concurrent.util.Atomic.*包中所有类的原子操作。
8.3 long和double的原子性
对于64位值的写入,可以分为两个32位操作进行写入,所以可能会导致64位的值发生错乱,针对这种情况可以添加volatile进行解决。在32位的JVM上它们不是原子的,而在64位的JVM上却是原子的。
8.4 原子操作+原子操作 != 原子操作
简单的把原子操作组合在一起,并不能保证整体依然具有原子性,比如说去银行取两次钱,这两次取钱都是原子操作,但是中途银行卡可能会被女朋友借走,这样就造成了两次取钱的中断。
9.JMM应用实例:单例模式的8种写法、单例和并发的关系
9.1 单例模式的作用
- 节省内存和计算
- 保证结果正确
- 方便管理
9.2 单例模式的适用场景
- 无状态的工具类:比如日志工具类,不管在哪里使用,我们需要的知识让它帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储状态,这时候我们就只需要一个实例对象即可。
- 全局信息类:比如在统计网站访问次数时,我们不希望一些结果记录在对象A上,一些记录在对象B上,此时可以创建一个单例的类进行计算。
9.3 单例模式的实现
- 饿汉式(静态常量,可用)
/**
* 饿汉式(静态常量)(可用)
*/
public class Singleton1 {
private static final Singleton1 INSTANCE = new Singleton1();
private Singleton1(){
}
public Singleton1 getInstance(){
return INSTANCE;
}
}
- 饿汉式(静态代码块,可用)
/**
* 饿汉式(静态代码块) (可用)
*/
public class Singleton2 {
private static Singleton2 INSTANCE;
static {
INSTANCE = new Singleton2();
}
private Singleton2(){}
public static Singleton2 getInstance(){
return INSTANCE;
}
}
- 懒汉式(线程不安全,不可用)
/**
* 懒汉式(线程不安全) (不可用)
*/
public class Singleton3 {
private static Singleton3 INSTANCE;
private Singleton3(){}
public static Singleton3 getInstance(){
if (INSTANCE == null){
INSTANCE = new Singleton3();
}
return INSTANCE;
}
}
因为这样写在多线程情况下有可能线程1进入了if (INSTANCE == null)
但还没来得及创建,此时线程2进入if (INSTANCE == null)
,这样就造成了重复的创建,破坏了单例。
- 懒汉式(线程安全,同步方法,不推荐用)
/**
* 懒汉式(线程安全,同步方法) (不推荐用)
*/
public class Singleton4 {
private static Singleton4 INSTANCE;
private Singleton4(){}
public synchronized static Singleton4 getInstance(){
if (INSTANCE == null){
INSTANCE = new Singleton4();
}
return INSTANCE;
}
}
因为添加了synchronized
关键字,所以可以保证同一时刻只有一个线程能进入方法也就保证了线程安全。但是由于添加了synchronized
也会对性能产生影响
- 懒汉式(线程不安全,同步代码块,不可用)
/**
* 懒汉式(线程不安全,同步代码块) (不可用)
*/
public class Singleton5 {
private static Singleton5 INSTANCE;
private Singleton5() {
}
public static Singleton5 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton5.class) {
INSTANCE = new Singleton5();
}
}
return INSTANCE;
}
}
这样写看似可行,可是实际上却不可以。因为只要INSTANCE
为空就会进入判断,无论里面加不加同步早晚都会再次创建,所以这样会导致实例被多次创建
- 双重检查
/**
* 双重检查(推荐面试使用)
*/
public class Singleton6 {
private volatile static Singleton6 INSTANCE;
private Singleton6() {
}
public static Singleton6 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton6.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton6();
}
}
}
return INSTANCE;
}
}
优点:线程安全,延迟加载,效率高
为什么要double-check?单check行不行?
因为如果不进行第二次检查无论添不添加同步都会对实例进行创建,这样就会创建多个实例,是线程不安全的
如果把synchronized
添加在方法上可以吗?
如果添加在方法上是可以的,但是这样会造成性能问题
为什么一定要加volatile
因为新建对象不是原子操作,它需要经过创建空对象、调用构造方法、将地址分配给引用这三个步骤,这样可能会进行重排序,所以就可能出现空指针异常,针对这个问题可以添加volatile
关键字来解决
- 静态内部类(推荐用)
/**
* 静态内部类式,可用
*/
public class Singleton7 {
private Singleton7() {
}
private static class InnerClass{
//不会对内部静态实例进行初始化
private static Singleton7 INSTANCE = new Singleton7();
}
public static Singleton7 getInstance(){
return InnerClass.INSTANCE;
}
}
静态内部类方式是一种“懒汉”的方式,在最初对类加载时不会加载内部类的静态实例
- 枚举单例
/**
* 枚举单例
*/
public enum Singleton8 {
INSTANCE;
}
9.4 对比单例模式实现方案
- 饿汉:简单,但是没有lazy loading
- 懒汉:有线程安全问题
- 静态内部类:避免了线程安全问题和资源浪费的问题,但是会增加编程的复杂性
- 双重检查:与JMM相关
- 枚举:写法简单、先天线程安全、避免反序列化破坏单例