java 多线程理论篇章(一)

java 多线程理论篇章

首先 在我们着手多线程的开发的时候需要对下面几个问题进行理解

  • 多线程的目的是为了什么?

  • 什么时候会导致线程不安全,他的本质是什么?

  • Java是怎么解决并发问题的?

  • 线程安全有哪些实现思路?

 

1、多线程的目的以及带来的问题

其实目的也很明确,在网络进化的过程当中,原有的单CPU,单内核的机器没有办法满足我们的需求,比如一个3GHz的CPU,意味着每秒可以执行30亿次的时钟周期,也就是大概1纳秒可以执行一次简单的操作。内存的一次寻址时间是1-10微秒, CPU如果等内存一次寻址,就意味着大概要等1000-10000个时钟周期(CPU运行的时间单位是cycle, 称为时钟周期,一次简单的运算大概在1-2个时钟周期)。

如果把CPU看作一个人,1个时钟周期看作1秒,那么就要等100万秒,一天是8万秒左右,那么这个人大概要等12天左右,正是因为一次IO等待,需要太久的时间,所以当一个线程等待IO时,我们可以让CPU去调度其他的线程做想要做的事情,充分利用起来,这就是需要多线程的原因之一。

其实提升Hz也可以,但是频率的上升会使发热量以平方的为单位上升,如果是超过5Ghz那么计算机可以当烤炉报废了,所以引入到了我们多核CPU的概念,使每个核的cpu可以独立完成一份工作,并行的调用程序。

2、什么时候会导致线程不安全,他的本质是什么?

多线程目的是可以让我们可以在短时间完成任务,提升效率,然而由于是对多个CPU进行把控,同时IO设备以及程序编译的问题,多速度增快的同时也为我们带来了如下的烦恼。

  • CPU 增加了缓存,以均衡与内存的速度差异;// 导致 可见性问题

  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致 原子性问题

  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致 有序性问题

 

  • 不安全多线程案例

/**
* @description:
* @author:achims
* @create:2021-06-08 08-48
**/
@Data
public class UnSafe {

  int count = 0;

  public static void main(String[] args) throws InterruptedException {
      UnSafe unSafe = new UnSafe();
      CountDownLatch countDownLatch = new CountDownLatch(1000);
      for (int i = 0; i < 1000; i++) {
          new Thread(() -> {
              unSafe.count++;
              countDownLatch.countDown();
          }).start();
      }
      countDownLatch.await();
      System.out.println(unSafe.count);//数据总是比1000小
  }
}

可见性:CPU高速缓冲区引发的血案

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10,这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

java中使用到了volatile关键字来保证数据可见性

 

原子性:分时的复用问题

所谓原子性就是数据执行的时候要么都成功,要么都失败,

经典的转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  • 数据执行


x = 10;       //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3

其实从中可以看到,数据的读取以及赋予会经过几次操作,如何保证这几次操作只被一个线程执行那么数据就时原子性质的。那就需要我们的锁了。而如果说到锁会涉及到 我们的显示锁lock 以及隐式锁synchronized。保证在单一时间数据是只有一个线程访问 就可以保证数据的原子性

 

重排序

你以为执行的方式并不是你以为的,因为为了使得数据执行提升性能,编译器以及数据处理器会对我们的指令进行重新边排,边排的分类主要分为三种:

 

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

3、Java是怎么解决并发问题的?

JMM

JMM(java内存模型) 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

// Processor A
a = 1; //A1  
x = b; //A2

// Processor B
b = 2; //B1  
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

 

img

 

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。

所以为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

  • LoadLoad Barriers:确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。

  • StoreStore Barriers:确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。

  • LoadStore Barriers:确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。

  • StoreLoad Barriers:确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

happens-before- 保证有序

从目的来说 happens-before的目的是为了保证数据的有序性,当然synchronized也可以保证数据的有序

在java5之后,java使用了JSP-133的内存模型,而其提出了经典的happends-before,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间 。与程序员密切相关的 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。

  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前

而对于happens-before有如下几种规则:

  • 单一性质:单线程执行中,程序前后的操作先行发生于后面的操作

  • 管程锁定:一个unlock操作先行发生于后面对同一个锁的lock的操作

  • volatile变量:volatile变量的写发送于后面对变量的读取

  • 线程启动规则:一个线程start方法调用发生在此线程执行的每一步之前

  • 线程加入:Thread对象的结束先行于join方法返回

  • 线程中断:interrupt方法调用比被中断线程的代码检测到中断事件的发生早

  • 对象终结:一个对象初始化先发生于finalize(线程死亡)方法的调用

  • 传递性:若A比B先行,B比C先行,那么A先行发送C

volatile - 保证可见

关于volatile可以理解为轻量级的synchronized,同时保证数据之间的可见性质,关于volatile的后续会进行深入理解

线程安全- 保证原子

解决线程安全方式其实有很多:

  • 线程独享:ThreadLocal

  • 乐观锁(无锁同步):CAS

  • 互斥锁:Synchronized,ReentrantLock(底层基于AQS)

 

关于线程安全类和接口会在后面的解除中进行详细参数,那么进行基于无锁以及互斥锁来对线程安全来进行说明

  • 隐士锁 synchronized

    对于 synchronized 可以对class,this对象以及 方法上进行加锁 , 后续如果有空可以给大家讲讲class文件的分配,主要记住synchronized 内部就是基于monitorenter 已经 monitorexit的方式进行数据的同步安全就行了

  • 无锁(CAS)

    为什么CAS会出现,是因为我们如果使用隐式锁太慢了,如果数据过来直接加一把锁因为用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作来保证数据的同步。而CAS则是一种乐观机制,乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

     

 

 

相关连接

 

这个世界慢死了

 

posted @ 2021-06-08 15:07  leayun  阅读(85)  评论(0编辑  收藏  举报