技术自查第五篇:线程进阶篇

5 篇文章 0 订阅
5 篇文章 0 订阅

JAVA的内存模型

官方说法:JAVA内存模型(JAVA MENORY MODEL),简称JMM,是一种符合内存模型规范,屏蔽硬件和操作系统的差异,保证java在各个平台对内存访问能保持一致的机制和规范

简单概括:屏蔽硬件与操作系统的差异,保证运行结果一致的规范

JMM与CPU和内存分不开,要了解JMM,先了解CPU与内存之间的交互

CPU与内存之间的交互

我们都知道处理器(简称CPU),读写速度比内存的读写速度快几个数量级,为了解决这种速度问题,CPU和内存之间加入了高速缓存和缓存读写一致协议,保证了高速缓存最后的数据与主内存一致。如下图:

解决速度问题 

上图可以看出每个出来CPU都有属于自己的高速缓存,而每个高速缓存都含有一级缓存(L1),二级缓存(L2)和三级缓存(L3)等等,而CPU需要使用数据时,就会从一级缓存获取,如果没有则从二级缓存获取,如此类推,最后从主内存获取。这样操作解决了CPU与内存之间的速度问题,但却不能解决高速缓存与主内存之间的数据差异问题?

解决数据不一致问题

为了解决高速缓存与主内存数据不一致问题,硬件层使用了缓存一致性协议,这里涉及到硬件层,就不展开说,但有个特别点:内存屏障

内存屏障

分为

1. 读屏障

2. 写屏障

作用是:

1. 禁止屏障两侧的指令重排序问题(指令重排序是重点)

2. 强制把高速缓存区的脏数据写到内存中,导致脏数据失效

JMM(JAVA的内存模型)与CPU和内存之间的交互非常相似,可以说参考了它们而进行设计规范的

线程与内存

我们的实际开发环境中,线程是多个的,例如用户线程和守护线程。而它们之间肯定涉及到与主内存进行数据交互,如何保证线程之间的数据互通和解决数据一致问题,这就是我们接下来所学习的。

从上图得知,内存都为每个线程分配对应的工作内存,而线程通过工作内存获取主内存的数据,而工作内存与主内存,使用JMM规定的save和load操作保证了数据一致。

JMM规定:所有变量都存放到主内存,而线程对变量的读写只能在工作内存,不能直接在主内存对变量进行读写

线程与工作内存与主内存

线程与工作内存与主内存之间的具体交互如下图

1. 线程A想要获取变量X,通知工作内存获取变量X

2. 线程A使用完毕之后,通过工作内存把变量X回写到主内存中,且通知线程B获取变量X

3. 线程B采用与线程A一样的操作获取变量X

上述的过程还是比较简单,如果再深入一点,就涉及到JMM与主内存之间的具体交互过程

JMM与主内存之间的交互操作

JMM规定了八种操作来完成与主内存之间的交互

1. lock:老朋友,对一个变量进行锁定,锁定时,只允许一个线程持有该变量。

2. unlock:另一个老朋友,对一个变量进行解锁。

3. read:读取主内存的变量到工作内存

4. load:把工作内存的变量存放到变量副本中

5. use:把变量传递给线程

6. assign:线程传递回变量给工作内存的变量副本中

7. store:把变量从变量副本移动到工作内存中

8. write:把工作内存中变量回写到主内存

除了以上这八种操作,JMM还规定使用这八种操作的准则

1. read和load,store和write都必须严格遵守使用顺序,但没规定说要连续执行

2. read和load,store和write,lock和unlock缺一不可。

3. 线程使用变量后,必须要把变量回写到主内存

4. 一个变量同一时刻只允许被一个线程lock操作,同一个线程对变量进行多次lock,也必须进行对应次数的unlock

5. 变量被lock,则会清空当前在工作内存的变量,重新执行load和read

6. 如果变量没被lock,则不可以使用unlock

多线程的三大特性

  • 原子性:每个操作都是单一不可分割的
  • 可见性:线程对共享变量的修改,其他线程是马上可见的
  • 有序性:程序执行的时候,代码执行顺序与语句顺序一致

八种操作和对应的使用准则,都在告诉我们多线程的三大特性原子性,可见性和有序性,每种操作都具有原子性,当多线程不符合上述任意一个特性时,就会产生线程不安全问题。

示例代码

/**
 * @author Leo
 * @description 线程原子性例子
 * @createDate 2021/9/20 21:10
 **/
public class AtomicExample {
    private static AtomicInteger atomicCount = new AtomicInteger();
    private static int count = 0;

    private static void add() {
        atomicCount.incrementAndGet();
        count++;
    }

    public static void main(String[] args) {
        final int threadSize = 1000;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executor.execute(() -> {
                add();
                countDownLatch.countDown();
            });
        }
        System.out.println("atomicCount: " + atomicCount);
        System.out.println("count: " + count);
    }
}
打印日志:
atomicCount: 1000
count: 999

在运行上述代码时,预期的结果:

atmoicCount : 1000

count : 1000

但实际结果:

atmoicCount : 1000

count : 999

产生原因是:

线程池中有1000个线程,当其中一个线程获取到的是另一个线程修改完毕后但未回写到主内存时的数据,这样就会导致线程获得数据是脏数据,导致结果与预期不符,也就是所谓的线程不安全。如下图

 额外知识

AtomicInteger是什么?

详细可看番外篇七:atomic的原子类

JMM,CPU与内存之间的交互为我们解答了线程的基本运行过程,但随之而来也产生一个问题,如何保证线程安全?

线程安全

1. 多线程的读同步与可见性(多线程缓存与指令排序

2. 多线程写同步与原子性(多线程竞争)

多线程的读同步与可见性(多线程缓存与指令排序)

多线程缓存导致可见性

正如上图,多线程从主内存获取数据时,并不知其他线程已修改该数据且回写到主内存,导致工作内存获取的数据是不正确。

解决办法

1. 使用volatile关键字,volatile关键字可保证可见性,即该线程修改变量时,其他线程可马上得知

2. 使用synchronize关键字,老朋友了,把对象进行加锁,同一时间只有一个线程持有,保证了变量的准确性

3. 使用final关键字,final可保证基本类型变量不可更改数据,保证引用类变量地址不发生改变,但还是不能保证引用类型的属性变更,原理,被final修饰的变量,初始化时没有this引用传递出去,造成this逃逸

额外知识点:

this逃逸:方法中开启另一个线程,这个线程引用的是你要构造的对象值,这时候是不安全的,因为无法保证每个线程获取的是对象初始化完毕后的值,有可能获取得到初始化一半的值,这就是this逃逸

JDK11中已修复该问题

指令重排导致可见性

Java有句话,如果在本线程内观测,所有操作都是有序的,但如果从一个线程观察另一个线程,所有操作都是无序的

在CPU与内存之间交互原理中,提到过内存屏障,它禁止了屏障内外两侧的读写指令重排,保证了缓存一致。

同理,线程与主内存之间也存在这指令重排这问题。

请看下面代码

int a = 1;
int b = 2;
int c = 3;
sout(a);
sout(b);
sout(c);

最后输出结果

a=1,b=2,c=3

虽然上述代码输出最后结果与我们的预期结果一致,但实际上代码执行的顺序却不一定a,b,c执行,因为上述变量是没有依赖的。

原因是:

1. java编译器的优化:为了优化编译和程序执行性能,在不影响单线程语义的情况下,可以重新安排执行顺序

2. 处理器的指令级并行重排:现代处理器采用了指令级并行技术,如果数据不存在依赖,是可以将多条指令重叠执行,改变执行顺序

        数据依赖

                读后读:a=1,a=2

                读后写:b=a,a=1

                写后读:a=1,b=a

3. 内存系统的指令重排:由于CPU与内存之间采用了高速缓存,这使得save和load操作都是无序的

指令重排过程

源代码 - > java编译器指令重排 - >  处理器的指令级并行重排 - > 内存系统的指令重排 - >最后执行顺序

那么如何解决指令重排问题呢?

解决办法:

当代码中存在着数据依赖,编译器生成指令序列时,就会插入特定的内存屏障指令,通过内存屏障指令,禁止编译器,处理器和内存系统的指令重排

JMM规定两种规则

如果再深入了解,就涉及到JMM规定两种规则

  1. as-if-serial规则
  2. happens-before规则

as-if-serial

意思:指令无论怎么排序,单线程执行的结果不能被改变

happens-before

如果一个操作是对另一个操作可见的,即两个操作之间存在happens-before,即使两个操作不在同一线程内(跨线程),那么执行顺序同样不会发生改变

但还存在较为特殊点:虽然两个操作之间存在happens-before,但如果重排序后,最后的执行结果与我们的预期结果一致,则允许这种重排的

happens-beforte还细分六大规则

1. 程序顺序执行,一个线程的每个操作必须happens-before与该线程任意后续操作

2. 监视器规则:对一个锁解锁,必须happens-before加锁

3. volatile规则:对一个volatile域的写,必须happens-before对这个volatile域的读

4. 传递性规则:如果A hb B ,Bhb C,则A hb C

5. start()规则:如果线程A执行线程B的start,那么线程A的线程Bstart操作必须hb线程B中的任意操作

6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

两者规则的区别

1. as-if-serial保证单线程的执行结果不被改变,happens-before保证多线程之间执行结果不被改变

2. as-if-serial给了我们单线程程序执行顺序不会改变幻觉,happens-before给了我们多线程执行顺序不会改变幻觉

3. 两种语义的目的都是为了不改变执行结果,尽可能提高程序执行的并行度

多线程的写同步与原子性

原子性:指每个操作都按原子的方式执行。要么该操作不被执行,要么该操作执行,且中途不可被其他线程中断

还是拿这张图说明

如果线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增加了两次,每个CPU缓存中一次。如果这些增加操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增加了两次:

解决这个问题可以使用Java同步块。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。

额外知识点:

单例模式的饿汉式的线程安全

有时候,我们需要设计一个单例模式的对象来解决问题或完成需求,即构造器私有化。

示例代码

/**
 * @author Leo
 * @description 饿汉式对象
 * @createDate 2021/9/18 15:32
 **/
public class EhanDemo {

    private static EhanDemo instance = null;

    private EhanDemo() {
    }

    public static EhanDemo getInstance() {
        if (instance == null) {
            instance = new EhanDemo();
        }
        return instance;
    }
}

此时,如果以该对象作为锁,多个线程调用时,可能判断该对象为null,这时会产生多个该对象,最后导致线程不安全。

为了解决,需要在getInstance()方法加上synchronize关键字,原因,当synchronize修饰静态方法时,对象锁就变成当前类的本身。

/**
 * @author Leo
 * @description 饿汉式对象,线程安全
 * @createDate 2021/9/18 15:32
 **/
public class EhanDemo2 {

    private static EhanDemo2 instance = null;

    private EhanDemo2() {
    }

    public static synchronized EhanDemo2 getInstance() {
        if (instance == null) {
            instance = new EhanDemo2();
        }
        return instance;
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值