首页 > 编程语言 > java多线程:基础详解
2022
03-17

java多线程:基础详解

Java内存模型

  • Java内存模型与Java内存结构不同,Java内存结构指的是jvm内存分区。Java内存模型描述的是多线程环境下原子性,可见性,有序性的规则和保障。
  • Java内存模型提供了主内存和工作内存两种抽象,主内存指的是共享区域 ,工作内存指的是线程私有工作空间。
  • 当一个线程访问共享数据时,需要先将共享数据复制一份副本到线程的工作内存(类比操作系统中的高速缓存),然后在工作内存进行操作,最后再把工作内存数据覆盖到主内存。主内存和工作内存交互通过特定指令完成。
  • 如下为并发内存模型图

在这里插入图片描述

多线程环境下原子性,可见性,有序性分别指的是

  • 原子性:程序执行不会受到线程上下文切换的影响。
  • 可见性:程序执行不会受到CPU缓存影响。
  • 有序性:程序执行不会受到CPU指令并行优化的影响。

主内存和工作内存的交互命令

  • lock:把主内存的一个变量标记为一个线程锁定状态。
  • unlock:把主内存中处于锁定状态的变量释放出来。
  • read:把主内存的变量读取到线程工作内存。
  • load:把工作内存的值放入工作内存变量副本中。
  • use:把工作内存变量的值传递给执行引擎。
  • assign:把执行引擎接收到的值赋值给工作内存变量。
  • store:把工作内存的值传送到主内存中。
  • write:把工作内存的值写入到工作内存变量。

内存模型的原子性

Java内存模型只保证store和write两个命令按顺序执行,但不保证连续执行,因此多个线程同时写入共享变量可能出现线程安全问题。

诸如i++的操作,首先将主存中的变量i的值拷贝一份拿到线程的本地内存,在本地内存进行自增操作,然后将新的i值写回主存。
但是涉及到多线程环境下的线程上下文切换就会出现问题,可能线程1将i值拿来进行自增操作,然后还来不及写回主存,时间片用完,轮到线程2执行,线程2对i进行自减操作,然后轮到线程1时,线程1将上一次的值写回内存,就会将线程2上一步的计算结果覆盖,就会产生错误的结果。

通过多线程的学习我们知道,对共享数据加锁可以保证操作的原子性,相当于i++操作对应底层命令是原子化绑定的,这样就不会出现线程安全问题,但是会导致程序性能降低。

内存模型的可见性

  • 对于频繁从主存取值的操作,JIT可能会将其进行优化,以后每次操作不从主存取值,而是从CPU缓存中取值。一旦线程1每次从寄存器取值,那么此时主存中变量值的变化对于线程1来说就是不可见的。
  • 如下,子线程是无法感知主存中flag的修改的,子线程就无法停止。
    public class Test {
        static boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
            //3秒后线程无法停止
            new Thread(()->{
                while(flag){
                }
            }).start();
            Thread.sleep(3000);
            System.out.println("flag = false");
            flag =false;
        }
    }
    
  • 有两种方法可以保证主存中数据的可见性,方法1是加锁。加锁既可以保证原子性,又可以保证可见性。
    public class Test {
        static boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
                    new Thread(()->{
                        while(flag){
                            synchronized (Test.class){}
                        }
                    }).start();
                    Thread.sleep(3000);
                    System.out.println("flag = false");
                    flag =false;
                }
    }
    
  • 还有一种方法是使用volatile关键字,它可以保证当前线程对共享变量的修改对另一个线程是一直可见的。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但是volatile关键字只能保证可见性,不能保证原子性。volatile适用于一个线程写多个线程读的应用场景,保证各个线程可以实时感知到其他线程更新的数据。
    public class Test {
        static volatile boolean flag = true;
        public static void main(String[] args) throws InterruptedException {
                    new Thread(()->{
                        while(flag){
                        }
                    }).start();
                    Thread.sleep(3000);
                    System.out.println("flag = false");
                    flag =false;
                }
    }
    
  • 对于多线程同时操作共享变量的情况,使用volatile关键字依然会出现线程安全问题,因为原子性无法保证。
public class Test {
    static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            for(int i=0;i<100000;i++){
                a++;
            }
        }).start();
        new Thread(()->{
            for(int i=0;i<100000;i++){
                a--;
            }
        }).start();
        Thread.sleep(1000);
        System.out.println(a); //不能保证a为0
    }
}

内存模型的有序性

有序性是指在单线程环境中, 程序是按序依次执行的。而在多线程环境中, 程序的执行可能因为指令重排而出现乱序。

指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。这种排序(比如两个变量的定义顺序)不会影响单线程的结果,但是会对多线程程序产生影响。

比如 a=1 b=2两条语句就可能发生指令重排。而 a=1,b=a+1 不会发生指令重排。

示例:线程1执行f1方法,线程2执行f2方法。两个线程同时执行,可能发生如下结果: f1中发生指令重排 flag=true先执行,a=1后执行。线程1先执行flag=true,然后轮到线程2执行,此时flag为true,执行if语句,i=1。这就是指令重排造成的程序错乱。

class Test{
    int a = 0;
    boolean flag = false;
    public void f1() {
        a = 1;                   
        flag = true;           
    }
    public void f2() {
        if (flag) {                
            int i =  a +1;      
        }
    }
}

可以用volatile修饰flag来禁用指令重排达到有序性。

加锁也可以避免指令重排带来的混乱,但是本身并没有禁止指令重排,因为保证了原子性,所以即使指令重排在同步代码块中依然相当于单线程执行,也不会有逻辑上的错误。

指令重排优化的底层原理

一个指令的执行被分成:取指、译码、访存、执行、写回 5个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令阻塞。相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。

比如,依次有两条指令a和b需要执行,如果是串行执行,它们的执行过程如下

指令a                          指令b
阶段1 阶段2 阶段3 阶段4 阶段5    阶段1 阶段2 阶段3 阶段4 阶段5

但是,假如阶段2耗时很长,使用串行的方式就无法在一个阶段阻塞的时候去执行其他阶段。

如下就是流水线的方式来执行,当指令a的阶段2阻塞时,完全可以去执行指令b的阶段1,这样就提高了程序执行效率,最大程度利用CPU各个部件。

指令a                         
阶段1 阶段2 阶段3 阶段4 阶段5   
指令b
      阶段1 阶段2 阶段3 阶段4 阶段5

因此指令重排就是对于一个线程中的多个指令,可以在不影响单线程执行结果的前提下,将某些指令的各个阶段进行重排序和组合,实现指令级并行。

valatile原理

如下,假设对变量a用valatile关键字修饰。

valatile int a = 0;

那么,对变量a的写指令之后都会插入写屏障,对变量a的读指令之前都会插入读屏障。

a++;
//写屏障
//读屏障
int b = a;

写屏障会保证写屏障之前的所有对共享数据的改动都会同步到主存中。读屏障会保证读屏障之后对共享数据的读取操作都会到主存去读取。这样就保证了,每次对valatile变量的修改对其他线程始终是可见的,从而保证了可见性。

另外,写屏障会保证写屏障之前的指令不会被排到写屏障后面。读屏障会保证读屏障之后的代码不会排到读屏障前面。这样就保证了有序性。

如下,由于写屏障的存在,int b=1;语句只能排在 a++前面,不能颠倒顺序。

int b=1;
a++;
//写屏障

volatile与加锁的区别

volatile只能保证可见性和有序性,不能保证原子性,加锁既可以保证可见性 原子性 有序性都可以保证。

volatile只适用于一个线程写,多个线程读的情况,对于多个线程写的情况,必须要加锁。

加锁相对于volatile是更加重量级的操作,所以一般能用volatile解决的问题就不要加锁。

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响被操作B察觉。

先行发生原则–是判断是否存在数据竞争、线程是否安全的主要依据。先行发生原则主要用来解决可见性问题的。

如下代码

//以下操作在线程A中执行
i = 1;
//以下操作在线程B中执行
j = i;
//以下操作在线程C中执行
i = 2

如果A先行发生于B,B先行发生于C,那么必然j的值为1。如果A先行发生于B,B和C没有先行发生关系,那么j的值可能为1也可能为2。

Java内存模型存在一些天然的先行发生关系,这些先行发生关系不需要任何的同步操作,就可以保证其线程安全。

1、程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。

2、Volatile变量规则。对一个volatile修饰的变量,对他的写操作先行发生于读操作。

3、线程启动规则。Thread对象的start()方法先行发生于此线程的每一个动作。

4、线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。

5、线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。

6、对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。

7、传递性。A先行发生B,B先行发生C,那么,A先行发生C。

8、管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。

线程的三种实现方式

  • 使用内核线程实现
  • 内核线程就是直接由操作系统内核支持的线程,通过内核完成线程的切换。
  • 通过线程调度器来负责线程调度,即将线程任务分配到指定处理器。
  • 在用户态,每个内核级线程会一 一对应一个轻量级进程,就是通常所说的用户级线程,多个用户级线程可以组成一个用户进程。
  • 如下所示:p进程 LWP用户线程 KLT内核线程 Thread Scheduler 线程调度器

在这里插入图片描述

  • 由于内核线程的支持,每个用户线程都是独立调度单位,即使有一个用户线程阻塞了,也不会影响当前进程其他线程执行。但是用户线程切换 创建 终止都要内核支持,内核与用户态切换代价较高。
  • Java就是使用内核线程实现的,无论是windows还是linux都是基于内核线程实现的。
  • 使用用户线程实现
  • 操作系统内核只能感知到用户进程,用户进程为操作系统内核的基本调度单位。
  • 基于用户进程实现的用户线程,线程的创建 切换 销毁都是进程自己管理,与内核没有关系。因为操作系统只能把处理器资源分配到进程,那么线程的运行 阻塞 生命周期管理都要用户进程自己来实现。
  • 内核不参与线程调度,因此线程的上下文切换开销比较小,但是实现起来非常复杂,而且当一个用户级线程阻塞整个进程都会阻塞,并发度不高。

在这里插入图片描述

  • 混合模式实现
  • 用户线程和内核线程使用M对N的映射来实现,兼顾两者的优点。

在这里插入图片描述

总结

本篇文章就到这里了,希望能给你带来帮助,也希望您能够多多关注自学编程网的更多内容!

编程技巧