并发编程有三大特性分别是,原子性,可见性,有序性。会产生这些特性的根本原因是现在的服务器都是多CPU多核心数的,每个CPU都有自己单独的一套缓存和pc系统,而且程序在运行时按照JMM的规范,它们是需要先把数据从主内存中读取到工作内存(也就是运行这个线程的CPU的缓存中),对工作内存中的数据进行修改之后再写回主内存,在对自己工作内存数据的操作对其他CPU是不可见的,这才会导致并发编程会有以上三种特性。下面就三种特性的概念,产生的问题,和解决方案进行阐述
1、原子性
1.1、原子性的定义
在并发编程中,会出现多个线程同时修改同一个变量的情况,此时对应的临界区域(多个线程都运行的代码区域)不做任何的限制的话,就会出现,a线程把i变量由1修改成2了,还没有来得及给i赋值2的时候,b线程也对i进行操作,但是它拿到的是原来的1,这样a,b两个线程都+1可结果还是2,为了解决这个问题,就需要保证a,b线程在对i修改之后把i写回主内存这一系列的操作,没有其他线程进行干扰,一次性全部完成之后,然后其他的线程再对i操作,由此就引出了并发编程中的原子性的定义
- 原子性:保证多线程运行过程中,临界区域的代码在运行的时候,是不可分割不可被打断的,其间也不会有其他的线程对其进行干扰。
1.2、原子性的解决方案
1.2.1、通过synchronizd锁的方案
private static int count;
//通过synchronized实现
public static synchronized void increment(){
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
通过查看上面带synchronized底层编译的JVM操作指令可以发现,synchronized在JVM指令操作上在临界区域加上了monitorenter操作,直到临界区域完成才会有monitorexit(异常也会有一个monitorexit操作),退出操作
1.2.2、通过lock锁的方案
private static int count;
private static ReentrantLock lock = new ReentrantLock();
public static void increment() {
//通过lock的方式实现
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
ReentrantLock的底层是基于AQS实现的,它是通过CAS的方式维护一个state变量来实现锁的操作
- CAS(compare and swap)
CAS是一条CPU层级就支持的原子操作,根据CAS的定义:比较和交换,底层的实现原理是,当它要置换内存中某个位置的值时,它会先去比较下是否和预期的值一致,如果一致它才置换。 - CAS的缺点
它只能保证对一个变量的操作是原子性的,不能实现对多行代码实现原子性。 - CAS的问题
- ABA问题:一个变量一开始是A,经过修改之后由A变成B,之后又变成A,此时对于那些引用类型是有问题的,因为引用类型虽然变回了A,但是它引用的指向的地址里的具体内容很可能已经发生了变化。
- ABA问题的解决方案:在比较的时候不仅比较预期的值,还需要比较对应的版本号,这样在由A->B,又B->A的过程中,版本号肯定跟原先的不一致,由此可以判断出已经修改不能进行置换操作
1.2.3、通过ThreadLocal的方案
static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();
public static void main(String[] args) {
tl1.set("123");
tl2.set("456");
Thread t1 = new Thread(() -> {
System.out.println("t1:" + tl1.get());
System.out.println("t1:" + tl2.get());
});
t1.start();
System.out.println("main:" + tl1.get());
System.out.println("main:" + tl2.get());
}
ThreadLocal解决线程之间的原子方案是通过线程隔离实现的,直接把共享变量分配给每个线程,每个线程只能操作自己的这个变量,把共享变量变成线程私有的变量
- ThreadLocal的底层实现
1、每个线程都有一个ThreadLocalMap对象作为Thread的成员变量
2、调用ThreadLocal的set方法时会初始化对应Thread的ThreadLocalMap变量
3、把当前的ThreadLocal对象作为key,把对应要存的数据作为value存储
- ThreadLocal会产生内存泄露问题
由于一般情况在创建ThreadLocal的时候都会把它设置成Static,所以就算是a线程运行结束了,a线程的对应的ThreadLocal对象由于Static指向着所有不会释放内存,但是由于线程运行结束,线程产生的独有资源,ThreadLocalMap中的key和value应该释放掉,如果不释放新的线程不断产生,会不断的消耗系统内存,从而导致内存泄露
- 解决方案
上述ThreadLocalMap的key和value内存泄露问题,其中key的内存泄露,系统已经帮我们做好了
- 系统通过把key设置成WeakReference类型,能做到当这个线程运行完成,GC回收的时候就会把ThreadLocal对象回收。因为弱引用的特点是只要碰到GC回收,它就会被回收。
- 至于value的回收,需要我们在代码里手动的调用ThreadLocal中的remove方法把value释放掉
2、可见性
2.1、可见性定义
导致可见性问题的原因是多CPU独立运行时,只会修改各自的工作内存也就是CPU缓存数据,而且多个CPU之间是有独立的缓存和PC系统。
- 可见性:多线程在运行的时候一个线程修改了公共变量,其他的线程不能及时的见到
2.2、可见性的解决方案
2.2.1、volatile方案
//通过volatile修饰,控制t1线程的停止
private volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
上述代码中如果没有volatile修饰,flag在主线程里被修改成false,t1线程是不会感知到的,那volatile底层是怎么实现的呢?
- volatile实现细节
- volatile在JVM层面是通过带lock前缀的指令实现。
- JVM的指令在CPU层面是通过缓存一致性协议,如MESI协议(inter的协议)实现。
- volatile变量在写操作时,JVM会及时的把对应CPU的缓存行刷到主内存中,在volatile变量被修改之后,根据MESI协议,所有CPU中对应这行缓存行数据都会失效,得重新去主内存中读取。
2.2.2、synchronized或是lock方案
锁的方案是可以实现多线程间变量的可见性的。锁的定义就是让单个线程去修改共享变量,修改之后其他线程再去读取,这个时候其他线程读到的数据肯定是上个线程修改的最新数据
2.2.3、final方案
final修饰的变量初始化之后,就不能被修改,所有的线程拿到的都是同一个值,也是间接的实现了线程之间的可见性
3、有序性
3.1、有序性定义
由于CPU的运行速度和读取数据的速度相差好几个数量级的关系,所以现代CPU为了追求效率,会进行“乱序执行”,在运行到需要去内存中读数据的指令时,可能要花很长时间等待读取数据,在这些时间的等待中,在保证程序结果的最终一致性之后,CPU就进行指令重排,以提高效率,但是有些时候这中乱序是不被允许的。
- 有序性:让程序中的指令集按照顺序执行,不让CPU进行指令重排
3.2、有序性解决方案
3.2.1、volatile方案
//通过volatile关键词解决
private static volatile MiTest test;
private MiTest(){}
public static MiTest getInstance(){
// B
if(test == null){
synchronized (MiTest.class){
if(test == null){
// A , 开辟空间,test指向地址,初始化
test = new MiTest();
}
}
}
return test;
}
上述是一个单例模式的样例代码,如果不加volatile关键词,在new MiTest这个共享对象的时候,就会出现问题,对象的new过程在底层一共有三个操作指令,分别是,给对象开辟空间,给对象初始化,把地址引用赋值给变量。如果此时发生了指令的重排,顺序变成了,给对象开辟空间,把地址引用赋值给变量,给对象初始化,那么B线程此时就会拿到没有进行初始化好的对象使用,就会发生问题,通过volatile防止指令重排就可以解决这个问题。
- volatile防止指令重排的细节
它是通过内存屏障来实现的,内存屏障相当于是一条指令,在这条指令的前后操作指令不能重排,在JVM层面有对应的读写屏障,在CPU底层也有对应的读写屏障来具体支持