当前位置:网站首页 > Java基础 > 正文

java多线程基础实例



Java内存模型

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

在这里插入图片描述

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

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

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

内存模型的原子性

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

通过多线程的学习我们知道,对共享数据加锁可以保证操作的原子性,相当于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<;i++){ a++; } }).start(); new Thread(()->{ for(int i=0;i<;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来禁用指令重排达到有序性。

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

指令重排优化的底层原理

比如,依次有两条指令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 java多线程基础实例 阶段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。

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

线程的三种实现方式

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

总结

  • 上一篇: java基础582讲
  • 下一篇: java基础笔试解析
  • 版权声明


    相关文章:

  • java基础582讲2024-11-06 11:42:06
  • java基础实验报告一2024-11-06 11:42:06
  • JAVA编程基础训练2024-11-06 11:42:06
  • java基础可以做的小项目2024-11-06 11:42:06
  • 没有java基础能学Python2024-11-06 11:42:06
  • java基础笔试解析2024-11-06 11:42:06
  • 零基础java图片2024-11-06 11:42:06
  • java基础数字输出2024-11-06 11:42:06
  • java回调基础2024-11-06 11:42:06
  • java基础知识大全.pdf2024-11-06 11:42:06