并发编程底层原理
什么是“底层原理”
1.最开始,我们编写的 Java 代码,是 **.java 文件
2.在编译(javac 命令)后,从刚才的 .java 文件会变出一个新的 java 字节码文件(.class)
3.JVM 会执行刚才生成的字节码文件(**.class),并把字节码文件转化为机器指令
4.机器指令可以直接在 CPU 上运行,也就是最终的程序执行
JVM内存结构、Java内存模型、Java对象模型
- JVM 内存结构:和 Java 虚拟机的运行时区域有关
- Java 内存模型:和 Java 的并发编程有关
- Java 对象模型:和 Java 对象在虚拟机中的表现形式有关
JVM 内存结构
-
堆:FIFO。保存 new 出来的对象和数组,运行时动态分配。
JVM 只有一个堆区被所有线程所共享,堆置于二级缓存中,调用对象的速度相对慢,生命周期由虚拟机的垃圾回收机制定。 -
虚拟机栈:FILO。保存了基本数据类型,以及对象的引用。编译时就确定大小,且大小固定。
是暂存数据的地方。每个线程都包含一个栈区,栈存放在一级缓存中,存取速度快。 -
方法区:保存已经加载的各个 static 变量、类信息、常量信息、永久引用、方法等。
-
本地方法栈:保存与本地相关的(native)
与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务。 -
程序计数器
它的作用是当前线程所执行的字节码的行号指示器,在虚拟机的模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖计数器完成。
Java 对象模型
- Java 对象自身的存储模型
- JVM 会给这个类创建一个 instanceKlass,保存在方法区,用来在 JVM 层表示该 Java 类
- 当我们在 Java 代码中,使用 new 创建一个对象的时候,JVM 会创建一个 instanceOopDesc 对象,这个对象中包含了对象头以及实例数据。
Java 内存模型(JMM)
Java Memory Model
1.为什么需要 JMM
- C语言不存在内存模型的概念
- 依赖处理器,而不同处理器结果不一样
- 无法保证并发安全
- 需要一个标准,让多线程运行的结果可预期
2.JMM 是规范!!!
- 是一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序
- 如果没有这样的一个 JMM 内存模型来规范,那么很可能经过了不同 JVM 的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样,造成严重后果
3.JMM 是工具类和关键字的原理
- volatile、synchronized、Lock 等的原理都是 JMM
- 如果没有 JMM ,那就需要我们自己指定什么时候使用内存栅栏等,相当麻烦。而 JMM 帮助我们可以用同步工具和关键字来开发并发程序。
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 {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(3);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
two.start();
one.start();
latch.countDown();
one.join();
two.join();
String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
根据以上代码来分析什么是重排序
在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致(代码顺序:a=1;x=b,但执行可能发生顺序相反:x=b;a=1),代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序。
结果分析:
上述代码执行结果正常情况下会有以下 3 种情况:
- a=1; x=b(0) ; b=1; y=a(1),最终结果 x = 0; y = 1
- b=1; y=a(0) ; a=1; x=b(1), 最终结果 x = 1; y = 0
- b=1; a=1; x=b(1); y=a(1), 最终结果 x = 1; y = 1;
从上述结果可以看到,通常情况下锁的竞争(即代码执行顺序)只会发生在线程与线程之间,内部代码执行顺序是不会改变的。
但当发生了重排序时,就会出现第 4 种情况:
- x = b(0); y = a(0) ;这两个总是先执行
- 最终结果:x = 0; y = 0
- 线程之间内部代码执行顺序出现了“竞争”
重排序原本的作用是为了提高处理速度
可见重排序可以减少一些多余的操作,提高运行速度。
出现重排序的场景:
- 编译器优化 - JVM , JIT 编译器等
- CPU 指令重排 - 就算编译器不发生重排序,CPU 也可能对指令进行重排
- 内存的“重排序” - 线程 A 的修改线程 B 却看不到,以为数据没改变(可见性问题)
可见性
线程间只看到部分数据的改变,线程间数据的通信是通过主内存,可能发生看不到最新数据而看到的是初始数据的情况。
可见性例子
例子1:结果未必是 a = b = 3
/**
* 描述: 演示可见性带来的问题
*/
public class FieldVisibility {
volatile int a = 1;
volatile int b = 2;
private void change() {
a = 3;
b = a;
}
private void print() {
System.out.println("b=" + b + ";a=" + a);
}
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();
}
}
}
例子2:
/**
* 描述: 演示可见性带来的问题
*/
public class FieldVisibilityABCD {
int a = 1;
int b = 2;
int c = 2;
int d = 2;
private void change() {
a = 3;
b = 4;
c = 5;
synchronized (this) {
d = 6;
}
}
private void print() {
synchronized (this) {
int aa = a;
}
int bb = b;
int cc = c;
int dd = d;
System.out.println("b=" + b + ";a=" + a);
}
public static void main(String[] args) {
while (true) {
FieldVisibilityABCD test = new FieldVisibilityABCD();
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();
}
}
}
CPU有多级缓存,导致读的数过期
多级缓存:自底向上,速度越快,缓存的数据越少
- 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层
- 线程间的对于共享变量的可见性问题不是直接由于多核引起的,而是由于多级缓存引起的
- 如果所有核心都只用一个缓存,那么就不存内存可见性问题了
- 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主内存中。所以会导致有些核心读取的值是一个过期的值。
个人理解
- 假设线程A ,B的初始数据都由主内存公平分发
- 这些初始数据是内容相同的“两份”
- 线程A对初始数据的修改不会直接影响到线程B对初始数据的操作,即线程B中初始数据无变化
- 直到线程A将修改完的初始数据写回主内存,主内存重新将“初始数据”发给线程B,线程B才能看到初始数据已经变化
- 虽然 修改-写回-重发 这个过程很短暂,但仍然可能发生主内存在将新数据发给线程B之前,线程B已经在开始对 初始数据 进行操作了,这时就会发生数据错误
- 1.线程A,B 拿到相同数据 a = 0;
- 2.A 先进行 a + 1 操作,并将结果 a =1 写回主内存;
- 3.主内存拿到 a = 1,准备将 a = 1 发给 B
- 4.此时 B 还没收到 a = 1 的通知,就进行 a -1操作,a = -1,而实际上数据应该是 a - 1 = 1 - 1 =0,数据错误。
JMM 的抽象:主内存和本地内存
-
Java 作为高级语言,屏蔽了这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是 JMM抽象了 主内存 和 本地内存 的概念
-
这里说的本地内存并不是真的是一块给每个线程分配的内存,而是JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象
主内存和本地内存的关系
JMM 有以下规定
- 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
- 线程不能直接读写到主内中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
- 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中装来完成
所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。
Happens-Before 规则
1.单线程规则
2.锁操作(synchronized 和 Lock)
3.volatile 变量
4.线程启动
5.线程 join
6.传递性
7.中断
8.构造方法
9.工具类Happend-Before原则
- 线程安全的容器 get 一定能看到在此之前的 put 等存入操作
- CountDownLatch
- Semaphore
- Future
- 线程池
- CyclicBarrier
可见性问题的解决
使用 volatile 解决
- volatile 是一种同步机制,比 synchronized或者Lock相关类更轻量,因为使用 volatile 并不会发生上下文切换等开销很大的行为
- 如果一个变量被修饰成 volatile ,那么JVM就知道了这个变量可能会被并发修改
- 虽然开销小,但相应的能力也小,虽然 volatile 是用来同步的保证线程安全的,但是volatile做不到 synchronized 那样的原子保护,volatile仅在很有限的场景下才能发挥作用
volatile 适用的场合
- 不适用于 a++ 的数据修改场合
- 使用场合1:boolean flag,如果一个共享变量自始至终都只被各个线程赋值,而没有其他操作(+±-%*/),那么就可以用volatile来代替。
synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所有就足以保证线程安全 - 适用场合2:作为刷新之前变量的触发器
代码示例
/**
* 描述: volatile适用的情况1
*/
public class UseVolatile1 implements Runnable {
volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable r = new UseVolatile1();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((UseVolatile1) r).done);
System.out.println(((UseVolatile1) r).realA.get());
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
setDone();
realA.incrementAndGet();
}
}
private void setDone() {
//仅进行赋值操作
done = true;
}
}
volatile 的两点作用
- 可见性:读取一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
- 禁止指令重排序优化:解决单例双重锁乱序问题
volatile 和 synchronized 的关系
- volatile 在这方面可以看做是轻量版的synchronized:如果一个共享变量自始至终只是被各个线程赋值,而没有其他操作,那么就可以用volatile代替synchronized或者代替原子变量,以为赋值本身是原子性的,而volatile又保证了可见性,所以足以保证线程安全
小结:
- volatile修饰符适用以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如 boolean flag;或者作为触发器,实现轻量级同步
- volatile属性的读写操作都是无锁的,不能替代synchronized,以为他没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以是低成本的
- volatile只能作用于属性,我们用volatile修饰属性,这样compiles 就不会对这个属性做指令重排序
- volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主内存读取
- volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作
- volatile可以使得long和double的赋值是原子的
能保护可见性的措施:
vola、synchronized、Lock、并发集合、Thread.join()、Thread.start()等
追加:
对synch可见性的正确理解
- synchronized不仅保证了原子性,还保证了可见性
- synchronized不仅让被保护的代码安全,还近朱者赤
原子性
原子性的概念
- 一些列的操作,要么全部执行成功,要么全部不执行,不会出现执行一般的情况,是不可分割的
- ATM里取钱就是一种原子性,不会取一百却只给50
- i++ 的问题不是原子性的,以为线程会争抢锁然后对数据进行修改,可能出现数据错乱
- 用 synchronized 实现原子性
主要默认的三种原子操作
- 除 long 和 double 之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
- 所有引用 reference 的赋值操作,不管是 32 位的机器还是 64 位的机器
- java.concurrent.Atomic.* 包中所有类的原子操作
long 和 double 的原子性
- 问题描述:官方文档对于 64 位值的写入,可以分为两个 32 位的操作进行写入、读取错误、使用 volatile 解决
- 结论:在 32 位上的 JVM 上,long 和 double 的操作不是原子操作的,但是在 64 位的 JVM 上是原子的
- 实际开发中:商用Java虚拟机中不会出现
原子操作 + 原子操作 ≠ 原子操作
- 简单地把原子操作组合在一起,并不能保证整体依然具有原子性
- 比如去ATM机两次取钱是两次独立的原子操作,但是期间可能银行卡被人借去,也就是被其他线程打断并被修改(拿了我的钱)
- 全同步的 HashMap 也不完全安全
JMM 应用实例
1.为什么要使用单例
- 节省内存和计算
- 保证结果正确
- 方便管理
private Resource rs = new Resource();
public Resource getExpensiveResource(){
return ts;
}
public Resource(){
field 1 = //some CPU heavy logic
field2 = //some value from DB
field3 = //etc,
}
2.单例模式适用场景
- 无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是他帮我们记录日志信息,除此之外,并不需要在他的实例对象上存储任何状态,这时候我们就只需要一个实例对象即可
- 全局信息类:比如我们在一个类上记录网站的访问次数,我们不希望有的访问被记录在对象A上,有的却被记录在对象B上,这个时候我们就让这个类成为单例
3.单例模式 8 种写法、单例和并发的关系
饿汉式:提前实例,可能不需要用到
懒汉式:需要时再实例,节省资源
- 饿汉式:静态常量
/**
* 描述: 饿汉式(静态常量)(可用)
*/
public class Singleton1 {
private final static Singleton1 INSTANCE = new Singleton1();
private Singleton1() {
}
public static Singleton1 getInstance() {
return INSTANCE;
}
}
- 饿汉式:静态代码块
/**
* 描述: 饿汉式(静态代码块)(可用)
*/
public class Singleton2 {
private final 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;
}
}
- 懒汉式:线程安全,效率低。不推荐
/**
* 描述: 懒汉式(线程安全)(不推荐)
*/
public class Singleton4 {
private static Singleton4 instance;
private Singleton4() {
}
//需求多时得等待,一次只能一个
public synchronized static Singleton4 getInstance() {
if (instance == null) {
instance = new Singleton4();
}
return instance;
}
}
- 懒汉式:线程不安全,仍然会创建多个实例
/**
* 描述: 懒汉式(线程不安全)(不推荐)
*/
public class Singleton5 {
private static Singleton5 instance;
private Singleton5() {
}
public static Singleton5 getInstance() {
if (instance == null) {
//看似不会同时,但其实还是会创建多个实例。
//到了这一步前面是判断 instance == null 才执行
//但是代码进到此处后面就无法判断是否有人先创建实例了,即默认还没有创建,依旧各自创建新的实例
synchronized (Singleton5.class) {
instance = new Singleton5();
}
}
return instance;
}
}
- 双重检查:线程安全,延迟加载,效率较高。推荐使用。(单层检查会出现上一种方式的情况)
/**
* 描述: 双重检查(推荐面试使用)
*/
public class Singleton6 {
//volatile的使用
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;
}
}
该方式使用volatile的原因:
i) 新建对象实际上有 3 个步骤
ii)重排序会带来 NPE(空指针问题)
iii)防止重排序
volatile 确保在调用对象的时候,对象内部需要的都已经准备好了,而不是一个虽然实例好却什么都没有的对象–空指针
- 静态内部类:(懒汉式)推荐使用。
/**
* 描述: 静态内部类方式,可用
*/
public class Singleton7 {
private Singleton7() {
}
private static class SingletonInstance {
private static final Singleton7 INSTANCE = new Singleton7();
}
public static Singleton7 getInstance() {
return SingletonInstance.INSTANCE;
}
}
- 枚举:最好,推荐使用。
/**
* 描述: 枚举单例
*/
public enum Singleton8 {
INSTANCE;
public void whatever() {
}
}
以上 8 种单例实现方案哪种最好?
- Joshua Bloch 在《Effective Java》中明确表达过的观点:“使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已成为实现 单例 的最佳方法”
- 写法简单
- 线程安全有保障
- 避免反序列化破坏单例
各种写法的适用场合
- 最好的方法是利用枚举,因为还可以防止反序列化重新创建新的对象
- 非线程同步的方法不能使用
- 如果程序一开始要加载的资源太多,那么就应该使用懒加载
- 饿汉式如果是对象的创建需要配置文件就不适用
- 懒加载虽然好,但是静态内部类这种方式会引入编程复杂性
生成对象的过程是原子操作?
过程包括:
- 新建一个空的 Person 对象 p
- 把这个对象的地址指向 p (引用)
- 执行 Person 的构造函数
以上过程可能出现重排序,所以不是原子操作