高效并发
Java内存模型
JCP组织定义了一种Java内存模型,以前是在JVM规范中,后来独立出来成为JSR-133(Java内存模型和线程规范修订)
内存模型就是在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象,简单的说就是怎么样跟内存进行交互,怎么样读写内存,Java内存模型主要关注JVM中将变量值存储到内存和从内存中取出变量值这样的底层细节
Java中所有的共享的变量都存储在主内存中,每个线程都有自己的工作内存,工作内存中保存该线程使用到的变量的主内存副本拷贝;线程对变量的读写操作都应该在工作内存中完成;不同线程之间不能相互访问工作内存,交互数据需要通过主内存
Java内存间的交互操作
Java内存模型规定了一些操作来实现内存间交互(就是一个变量从主内存拷贝到工作内存,从工作内存同步回主内存的细节),JVM会保证他们是原子的,具体包含如下几个操作:
- lock:锁定,作用于主内存变量,将变量标识为线程独占
- unlock:解锁,作用于主内存变量,将锁定的变量释放,别的线程才能使用
- read:读取,将变量从主内存读入到工作内存中(并没有赋值到工作内存的副本变量上)
- load:载入,将read读取到的值放入工作内存的变量副本中(赋值给工作内存的副本变量)
- use:使用,将工作内存中的一个变量的值传递给执行引擎
- assign:赋值,将从执行引擎接收到的值赋给工作内存中的变量
- store:存储,将工作内存中的变量的值传递到主内存中(同样的,并没有赋值到主内存变量上)
- write:写入,将store进来的数据存入主内存的变量中
内存间交互操作的具有如下规则:
- 不允许read和load、store和write操作之一单独出现,以上两个操作必须按照顺序执行,但中间可插入其他操作
- 不允许一个线程丢弃他的最近的assign操作,即变量在工作内存中发生改变后必须将其同步回主内存
- 不允许一个线程无原因(没有assign操作)的将数据从线程工作内存同步回主内存,即变量在工作内存中未发生任何改变
- 一个公共变量只能从主内存中诞生,不允许在工作内存中直接使用一个未初始化的变量,也就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作
- 一个变量在一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
- 若对一个变量执行lock操作,将会清空工作内存中此变量的值(上次交互遗留的相同变量),在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
- 若一个变量没有被lock操作锁定,则不允许对他执行unlock操作,也不能unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先将此变量同步回主内存,也就是先执行store和write操作
volatile关键字
当一个线程修改了变量,其他线程也可感知到,常用的方式有:volatile、synchronized、final(一旦初始化完成,其他线程就可感知)
适合使用volatile关键字的场景有:运算结果不依赖变量的当前值,或者能确保只有一个线程修改该变量的值
volatile具有如下特性:
- 基本是是JVM提供的最轻量级的同步机制,用volatile修饰的变量,对其他的线程可见,即对volatile变量所作修改其他线程能立刻感知
- volatile修饰的变量是禁止指令重排序优化的
- 但多线程环境下仍然是线程不安全的,下面程序可进行测试,会发现每次最终输出结果不一样
class Model {
private volatile int i;
public /*synchronized*/ void add() { //加上synchronized后就可以达到线程安全的目的
i++;
}
public int getI() {
return i;
}
}
class MyThread implements Runnable {
private Model model;
public MyThread(Model model) {
this.model = model;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
model.add();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public class Main {
public static void main(String[] args) {
Model model = new Model();
Thread thread1 = new Thread(new MyThread(model));
Thread thread2 = new Thread(new MyThread(model));
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
//保证线程1和线程2先执行结束
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最终的i=" + model.getI());
}
}
指令重排序
指的是JVM为了优化,在条件允许的情况下,对指令流进行一定的重排序,目的是为了直接允许当前能立即执行的后续指令,避开回去下一条指令所需数据造成的等待
线程内存串行语义,不考虑多线程间的语义(线程内有序,线程间无序),并不是所有的指令都能重排,比如:
- 写后读:写一个变量后,再读这个位置,比如
a=1;b=a
- 写后写:写一个变量后,再写这个变量,比如
a=1;b=2
- 读后写:读一个变量后,再写这个变量,比如
a=b;b=1
在Java中指令重排还有以下规则:
- 程序顺序原则:一个线程内保证语义的串行性
- volatile原则:volatile变量的写,先发生于读
- 锁规则:解锁必然发生在锁后的加锁前
- 传递性:A先于B,B先于C那么必然A先于C
- 线程的
start()
方法先于任何动作 - 线程内的所有操作先于线程的终结,比如说
Thread.join()
方法 - 线程的中断(
interrupt()
方法)先于被中断线程的代码 - 对象的构造函数执行结束先于
finalize()
方法
下面代码可证明有重排序
public class Main {
private static int a = 0;
private static int b = 0;
private static int x = 0;
private static int y = 0;
public static void main(String[] args) {
while (true) {
a = b = x = y = 0;
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});
thread1.start();
Thread thread2 = new Thread(() -> {
b = 2;
y = a;
});
thread2.start();
try {
thread1.join();
thread2.join();
//保证线程1和线程2先执行结束
} catch (InterruptedException e) {
e.printStackTrace();
}
/**
* thread1先运行完毕,thread2再运行, a=1,b=2,x=0,y=1
* thread2先运行完毕,thread1再运行, a=1,b=2,x=2,y=0
* thread1和2交叉运行,thread1部分运行,thread2完整运行,a=1,b=2,x=2,y=1
* thread1和2交叉运行,thread1部分运行,thread2部分运行,a=1,b=2,x=2,y=1
* thread1和2交叉运行,thread2部分运行,thread1完整运行,a=1,b=2,x=2,y=1
* thread1和2交叉运行,thread2部分运行,thread1部分运行,a=1,b=2,x=2,y=1
* 出现重新排序,可能会出现 a=1,b=2,x=0,y=0
*/
System.out.println("a=" + a + ",b=" + b + ",x=" + x + ",y=" + y);
if (x == 0 && y == 0) {
System.out.println("证实了有重排序");
break;
}
}
}
}
线程安全的处理方法
- 不可变的一定是线程安全的
- 互斥同步(阻塞同步):synchronized、java.util.concurrent.ReentrantLock;目前这两种方式性能已经差不多,建议优先选择synchronized,ReentrantLock还增加了如下特性
- 等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待
- 公平锁:多个线程等待同一个锁时,必须严格按照申请锁的时间顺序来获得锁
- 锁绑定多个条件:一个ReentrantLock对象可以绑定多个condition对象;而synchronized是针对一个条件的,如果要多个,就得有多个锁
- 非阻塞同步:是一种基于冲突检查的乐观锁定策略,通常是先操作,若没有冲突,操作就成功了,有冲突再采取其他方式进行补偿处理
- 无同步方案:其实就是在多线程中,方法并不涉及共享数据,自然也就无需同步了
锁优化
自旋锁和自适应自旋锁
自旋:若线程可以很快获得锁,JVM就可以不在OS层挂起线程,而是让线程做几个忙循环
自适应的自旋:自旋时间不再固定,而是由前一个在同一个锁上的自选时间和锁的拥有者状态来决定
整个优化过程由JVM自动完成,无需人工干预,若锁被占用时间很短,自旋成功,那就节省了线程挂起以及切换时间,从而提升系统性能;若锁占用时间很长,自旋失败,就会浪费处理器资源,从而降低系统性能
锁消除
在编译代码时,检测到根本不存在共享数据竞争,也就无需同步加锁了,通过-XX:+EliminateLocks
来开启,同时要使用-XX:+DoEscapeAnalysis
开启逃逸分析
逃逸,其实就是方法内部东西被外界访问到了,分为以下两种情况
- 若一个方法中定义了一个对象,可能被外部方法引用,称为方法逃逸
- 若一个对象可能被其他外部线程访问,称为线程逃逸,比如:赋值给类变量或可以在其他线程中访问的实例变量
一旦存在逃逸,就意味着不能轻易将锁消除,无法知道外部是如何使用这些资源的
锁粗化
通常需要将同步块尽可能的小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的新能损耗,这种情况建议将锁同步的访问加大到整个操作序列
轻量级锁
轻量级锁是相对于传统锁机制而言的,本意是没有多线程竞争的情况下,减少传统锁机制使用OS实现互斥产生的性能损耗,就是类似于乐观锁的方式
若轻量级锁失败了,就表示存在竞争,从而又升级为重量级锁,导致性能下降
偏向锁
偏向锁,也就是偏心,锁会偏向于当前已经占有锁的线程,在无竞争的情况下,直接将整个同步消除了(包括乐观锁),从而提高性能
只要没有竞争,获取偏向锁的线程,在将来进入同步块,也无需做同步,当其他线程请求相同的锁时,偏向模式结束,也就是说若程序中大多数锁总是被多个线程访问时候,也就是竞争比较激烈,偏向锁反而会降低性能,使用-XX:-UseBiasedLocking
参数来禁用偏向锁,默认是开启的
JVM获取锁的步骤
- 会优先尝试偏向锁
- 再尝试轻量级锁
- 再尝试自旋锁
- 最后尝试普通锁,使用OS互斥量在OS层挂起
使用同步代码的基本规则
- 尽量减少锁持有的时间
- 尽量减少锁的粒度
Comments NOTHING