java并发-基础


前言

开启java并发包的学习。

一、操作系统相关

1. 进程与线程

进程是活动的程序。进程是在内存中为程序开辟的活动空间,是在运行中的程序。除了程序本身,进程还拥有各种控制信息。

在早期单道批处理系统中,没有进程的概念,程序只能一个接着一个顺序执行。而IO操作往往耗费大量的时间,若是一个程序为了等待IO而中断等待,效率极低。
为了解决这个问题,提出了多道批处理系统,系统中可以同时运行多道任务,但是这样资源分配调度就很混乱,为了更好地进行分配,提出了进程的概念。

有了进程,可以有多个程序同时在系统中运行。
但是有时候一道程序又有多个子任务,每个子任务都需要同步跟进的进行,若是一个一个的执行,其中一个崩溃程序就要重新开始,为了这个目的,将进程更加细的划分为线程,每个线程代表一个子任务。

有了线程之后,线程成为CPU调度的最小单位,进程是资源分配的最小单位

进程与线程的区别:

  1. 进程拥有独立的存储空间,实现同步很简单,资源共享困难;线程共享进程的空间,实现同步复杂,数据、资源共享很简单;
  2. 进程是重量级的,开启、销毁消耗很大;线程是轻量级的。
  3. 进程互相之间不会干预执行;线程需要协作执行。

2. 上下文切换

进程/线程之间的切换,需要记录前一个进程/线程的控制信息和数据,主要有CPU寄存器与程序计数器,而这些寄存器的内容就是上下文。

进程/线程切换时,会将上下文写入内存,在将新的进程/线程的信息、数据从内存读取到CPU的寄存器中。
上下文切换耗费资源,因此过多的线程会造成cpu吞吐量降低。

切换时机在一个CPU时间片用完:
CPU会为每个线程分配时间片【线程是调度单位】,当一个线程的时间片用完时,就会轮到下个优先级最高的线程占据CPU执行,轮换之下,直到所有程序执行结束。

3. 计算机存储结构划分

在这里插入图片描述
CPU超强的运算速度以及快速的发展,使得内存远远跟不上cpu的速度。为了缓解CPU要经常等待内存读写的惨状,计算机往往使用多级内存模型。
直接与CPU交互的只有高速缓存cache, 而cache会在空闲的时候刷新到内存中。
cache分为三级:L1,L2,L3
在这里插入图片描述
缓存一般都很小,我的电脑8G内存也只有几M的缓存
这是因为越高速的硬件约昂贵,只能选取折中的方式。

二、Java并发包概述

JUC【java.util.concurrent】,是Java语言在多线程领域引以为豪的部分,开发者主要是大名鼎鼎的Doug Lea

在这里插入图片描述

Java并发包涉及众多API,混合操作系统与jvm的知识,是一个综合性很高的部分。
同时也是Java八股文的重点。

在这里插入图片描述

三、JMM内存模型

在JVM中,为了保证内存数据的高速度读取,也采取了计算机缓存的机制。

将JVM内存划分为工作内存共享内存【主内存】两个部分。
对象数据会优先放在主内存中,当程序方法运行时,会将主内存的数据读取到栈帧上,使得方法使用数据可以就近取得。
在这里插入图片描述


为了实现主内存与工作内存数据的交互,Java语言规范规定了八个原子操作来操作数据。【这些指令统统使用汇编语言实现】:
在这里插入图片描述
以一个小case来演示则八个指令的使用。

public class Demo1 {

    private static boolean prepared = false;

    public static void main(String[] args) {

        new Thread(() -> {
            System.out.println("do preparing、、、、");
            try {
                Thread.sleep(50);
                while (!prepared){

                }
                System.out.println("word finished");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            System.out.println("helper");
            try {
                Thread.sleep(1000);
                prepared = true;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

这个例子大家都很熟悉。没加volatile的情况下,线程1不会执行while后面的语句,因为他的变量prepared并没有被线程2修改成功,一直在死循环。

为什么没加volatile会这样?
在这里插入图片描述
原子操作流程:

  1. 线程1最先执行【它睡得时间比较短】:在Demo1的主内存中【堆区存储静态变量】存储这isFlagd【就当做我那个prepared】变量。线程1从主内存read**找到并读取这个变量**,并通过load加载到工作内存中。
  2. 线程2也需要这个变量,他也会通过read、load读入工作内存;
  3. 线程2通过assigh修改赋值这个变量, 并通过store将这个变量存储到主内存中,最后通过write将这个变量写到这个变量对应的位置覆盖。【assign在虚拟机栈的操作数栈执行,store从虚拟机栈到主内存,write是在主内存执行】
  4. 但是,主内存变量的修改不会影响到线程1的工作内存,他使用的仍然是false的值,也就会一直死循环。

为什么volatile就可以让线程1的变量变化呢?

缓存一致性协议(MESI)

我们一般都知道volatile的作用是让一个线程对共享变量的修改立即被其他线程可见
但是这是非常浅显的认识,volatile真正的原理是依赖缓存一致性原则【MESI】
在这里插入图片描述
如上图,对比上上图,增加了一条内存总线的MESI与一个总线嗅探,正是这两个变化使得volatile有效。机制为:

  • 使用volatile的程序开启了总线的多级缓存一致性协议,使得所有CPU可以通过`总线嗅探``探查总线的读写数据变化。
  • 当线程2将值写入到静态变量isFlag的堆内存的位置后,线程1的CPU会嗅探到总线的数据变化,根据缓存协议,会将缓存行单元设为I,此后线程1的工作内存数据视为无效
  • 线程1因为死循环再次读取工作内存数据,发生被标记为无效,无奈之后去内存在读取一次,这次读取到线程2的修改值,死循环结束。

注意:为了保证数据的及时更新,线程2的数据修改行为会马上执行,而不是等待一段时间后执行【有丶像缓存的同步刷新】在这里插入图片描述

查看汇编程序的汇编代码:
线程2的修改行为(对应字节码putstatic)会被lock,这个标记就要求线程2立即将数据写入主内存,刻不容缓。
并且将线程1的工作内存数据设为无效。
在这里插入图片描述
在这里插入图片描述

四、并发三大原则

在这里插入图片描述

1. 可见

即共享数据的修改保证其他线程可见。【通过volatile实现】

2. 顺序

保证指令的有序执行【volatile实现】

什么是指令的顺序执行?
对于一个程序,JVM会在保证单线程结果没有影响的情况下,对指令的执行结果排序,使得程序具有更高的性能。【比如说,中间由IO操作,后面却是简单的运算,就会先进行计算,再考虑IO】
在编译器、字节码期、运行期都有可能指令重排。

指令重排

如下面的语句:
在每个Runnable中,a = y 与 x = 1谁先谁后对线程one没有任何影响【对其他线程影响管不着】,因此可能存在指令重排
【事实上,打印的结果就可能有 a = 1, b = 1】这种难以预料的结果【因为x和y的赋值语句都被重排移到到了第一句执行】
在这里插入图片描述


指令重排虽然可能有潜在的优化效益,但是可能会引发多线程下未知的情况。但是jvm的指令重排也不是瞎来的,而是严格遵守两个原则:as-if-serial和happens-before.

(1)as-if-serial
就是刚刚说的:保证在单线程情况下代码调换位置结果都是一样的。
意思就是,交换位置的代码之间,不会出现的依赖地关系。
如下面的代码,就能不满足这个as-if-serial【如a的赋值在前,那么x == y;
若a的赋值在后,那么 x == a。这样,若是a的值不等于y,结果就是不一样的】
我们说这两句赋值之间具有依赖关系,不能进行指令重排
在这里插入图片描述
注意:若变量在多线程有依赖关系,不影响指令重排。

指令重排依赖于JVM的语义分析。
在这里插入图片描述


(2)happens before
在这里插入图片描述
java语言中,有一些代码,必须要保证先后严格的执行顺序,才能遵守基本的语法规则【有些语句只能在另一些语句执行之前执行,因此不能重排序】

如加锁(lock())与解锁(unlock()),下一次的lock必须要在上一次的unlock之后才能执行。剩下的还有对象终结【finalize()】,线程中断[exit()]等等,必须要在执行逻辑之后执行。
这种需要严格遵守执行顺序的语句也不能进行指令重排。


一个指令重排造成的现实中的巨大bug分析【阿里线上】
双重检查锁(double-check-lock,dcl)对象半初始化问题:

我们经常见到一种单例模式的写法,包括jdk源码中也有在cas的前后检查是否为空的语句,这种行为我们称为DCL。
事实上,在高并发的环境下,DCL有一个小概念发生发生的问题,我们从他的字节码剖析。

代码:


public class Demo3 {
    private static Demo3 demo3;
 
    public static Demo3 getInstace(){
        if(demo3 == null) {
            synchronized (Demo3.class) {
                if (demo3 == null) {
                    demo3 = new Demo3();
                }
            }
        }
        return demo3;
    }
}

在这里插入图片描述
在回忆一下对象创建的流程:

  • 加载该对象存在的类及其父类、接口类等
  • new关键字分配堆内存;
  • 对象做默认初始化;【对象】
  • 设置对象头;
  • 显式初始化,调用构造方法;【invokespecial】
    <init>是c++实现的构造方法,初始化及调用java构造方法

而加锁对应的字节码是"monitorenter"以及“monitorexit”,录取其中的字节码:

10 monitorenter
11 getstatic #2 <com/peng/concurrent/volat/Demo3.demo3>
14 ifnonnull 27 (+13)
17 new #3 <com/peng/concurrent/volat/Demo3>
20 dup
21 invokespecial #4 <com/peng/concurrent/volat/Demo3.<init>>
24 putstatic #2 <com/peng/concurrent/volat/Demo3.demo3>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit

在执行对象创建的四句中【17~24】中,putstatic表示将这个对象赋值给静态变量demo3。
在jvm看来,21句的调用构造方法在单线程情况下并不影响后面的赋值语句,他有可能将21与24调换过来,这样若是我们的构造方法中的初始化逻辑代码很多,就会导致初始化得不到执行,最后将空壳对象返给调用者,造成bug。

解决的方法:
通过volitale关键字修饰返回的静态变量【现在已经是编程规范】,声名内存屏障。

内存屏障

内存屏障在JVM规范中由几条字节码指令实现:
(1)若两次读取之间要有顺序,就在两次load中添加LoadLoad字节码指令
(2)。。。。

而什么情况下jvm知道必须要在两条指令之间加屏障呢?
程序的必须保证顺序的变量显示标注volatile关键字
在这里插入图片描述


为什么valatile具有读写屏障功能?

volatile具有这四个屏障是JVM要求不同JVM实现必须具有的规范。
不同的JVM实现对于该指令具有不同的实现。大抵上都是通过lock这个汇编指令完成的

打开JDK源码,进入解释器源码《c++》。
if判断变量有无volatile修饰,若有进入下面的if中。
在语句块的最后,添加了storeload指令。点进入看看。
在这里插入图片描述
storeload()的实现是fence()方法
他会首先判断当前CPU的类型,并在汇编时添加汇编代码lock《asm就是汇编的意思》
可以看到lock指令的存在。
在这里插入图片描述

一旦编译的汇编指令出现了lock,那么这个lock前后的代码都不能实现指令重排序。
在这里插入图片描述

Lock前缀的指令在多核处理器下会发生两件事情:
1)将当前处理器的缓存行的数据写回到系统内存。
2)这个写回内存的操作会使其他CPU缓存了该内存的地址的数据无效。

在这里插入图片描述


这一块的知识有丶混乱,用点逻辑串起来:

  • 指令重排在单线程下可以提高程序效率,但是在多线程下会引发问题;
  • 为了保证不进行指令重排,一般CPU会使用内存屏障
  • 典型的内存屏障实现由两种:
  • (1)加LOCK#锁,说锁住系统总线【早期CPU使用,因为锁住总线会导致其他程序无法执行,导致系统缓慢】
  • (2)由Intel等提出的MESI原则,通过“伪lock”指令,即要求主内存更新马上写到告诉缓存,已经高速缓存更新马上写入主内存的方式,在逻辑上实现了内存屏障,且不会阻碍其他程序的执行。

更详细的理论,看这篇博客

3. 原子

保证一个线程的一个操作必须同时执行。
volatile并不保证原子性。
在这里插入图片描述

造成上面的例子的原因在于:

线程1得到值10被阻塞,而此时就单纯等待时间片加一在存储即可,不需要再读取了。
因此缓存的修改对他没有意义。

准确的说,“volatile只有对本身就不是原子的操作不具备原子性”
synchronized具有这种能力,这是因为synchronized会对i上锁,只有当前线程完成自己的任务,其他线程才有机会得到这个变量的使用权。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值