当前位置: 首页 > news >正文

Java内存模型与线程

实现多线程我们知道可以继承Thread、实现Runnable接口等,但是为什么就实现呢?这篇文章就是解释多线程实现的底层原理。

一、主内存和工作内存

 Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。

线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图。

Java 内存模型也主内存和工作内存之间的交互操作:
  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store write 操作。

二、原子性、可见性、有序性(并发编程三要素)

2.1 原子性

原子性指的是操作不可被中断,要么全部执行完成,要么完全不执行。
  • 原子性操作在执行时不会被其他线程干扰。

  • 如果多个线程同时访问共享资源,原子性可以防止数据的不一致。

Java 中的原子性

  • 原子性操作示例:

    • 读取和写入基本数据类型(如 intfloat)是原子性的。

  • 非原子性操作:

    • 复合操作(如 counter++ 或 counter = counter + 1)是非原子性的。这些操作实际上包括三步:读取变量值、修改值、写回变量。

解决方案

  • 使用 同步机制(如 synchronized 块或方法):

    1

    2

    3

    synchronized (lock) {

        counter++;

    }

  • 使用 原子类(如 AtomicInteger):

    1

    2

    AtomicInteger counter = new AtomicInteger();

    counter.incrementAndGet();

小结:
Java 内存模型来直接保证的原子性变量操作包括 read load assign use store write这六个,我们大致可以认为, 基本数据类型的访问、读写都是具备原子性的 (是 long double除外)。
为了保证一个用户自己定制的原子性范围,比如需要某个方法、某个代码块具有原子性,那就需要synchronized关键字了。synchronized关键字可以看成是比较高层次的字节码指令,底层就是把 lock unlock 操作。

2.2 可见性

可见性指的是一个线程对共享变量的修改对其他线程是可见的。

  • 在多线程环境中,如果没有同步机制,一个线程对变量的修改可能不会立刻被其他线程看到(由于 CPU 缓存或编译优化)。

  • 线程可能会一直使用自己 CPU 缓存中的值,而看不到其他线程更新后的值。

volatile修饰变量时其实就是使得这个变量具有可见性。无论变量是否被volatile修饰,Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。不同的是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外, Java 还有两个关键字能实现可见性,它们是 synchronized final

2.3 有序性

有序性指的是程序代码的执行顺序,通常来说,程序会按照代码编写的顺序执行,但编译器和处理器会为了优化性能进行 指令重排

  • 单线程中,指令重排不会影响程序的正确性。

  • 多线程中,指令重排可能导致意想不到的结果,因为线程之间的执行顺序无法预测。

解决方案

  • 使用 volatile:确保关键变量的修改不会被指令重排。

  • 使用 synchronized 或 显式锁:同步代码块可以强制线程按照指定顺序执行。

三者关系与 happens-before 原则

  • 原子性 和 可见性 是独立的,但有时需要结合使用才能实现正确的多线程行为。

  • 有序性 通常需要通过 volatile 或同步机制来确保。

  • happens-before 是 Java 内存模型中定义的一种原则,用于规定线程间的操作顺序:

    • 一个线程对变量的写操作对另一个线程的读操作可见,必须满足 happens-before 原则。

    • 如:synchronizedvolatile、线程启动/终止等操作会建立 happens-before 关系。

Happens-Before原则‌用于定义在并发编程中不同操作之间的可见性和顺序性。如果操作A Happens-Before 操作B,意味着操作A的执行结果对操作B可见,即操作B能够看到操作A对共享变量的修改。

基本概念

Happens-Before原则定义了操作之间的顺序关系,确保在并发编程中,当一个操作的结果需要被另一个操作访问时,前者必须先执行。这有助于解决多线程环境下的内存可见性问题,确保线程之间的操作按照特定的顺序执行。

主要规则

  1. 程序顺序规则‌:在同一个线程中,前面的操作Happens-Before后面的操作。
  2. 监视器锁规则‌:同一个锁的unlock操作happen-before此锁的lock操作。
  3. volatile变量规则‌:对volatile变量的写操作Happens-Before此变量的读操作。
  4. 传递性规则‌:如果操作A Happens-Before 操作B,且操作B Happens-Before 操作C,则操作A Happens-Before 操作C。
  5. 线程启动规则‌:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  6. 线程终结规则‌:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
  7. 线程中断规则对一个线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生(即先中断才会被检测到中断状态)。如果没有这个规则的话,线程被中断之后,Thread.interrupted()无法检查到中断发生,外部依赖的程序就无法正常执行。

  8. 对象终结规则‌:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。如果先执行finalize(),初始化的后半部分就会出现问题,因为找不到该对象了。 

应用场景和重要性

Happens-Before原则在并发编程中非常重要,它确保了在不同线程中对共享变量的访问和修改能够按照特定的顺序进行,从而避免了数据竞争和内存可见性问题。java设计者也考虑到这个问题,所以设计了happen-before原则,只要符合其中的规则,就不用担心可见性问题。通过理解这些规则,开发者可以编写出更安全、更可靠的并发程序。

三、volatile

上面也大致讲了volatile保证可见性的底层原因。

当一个变量被定义成 volatile 之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“ 可见性 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如, 线程A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程B 可见。
需要注意的是:volatile可以保证可见性,但却不能保证原子性。
volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题)。 但是Java里面的运算操作符并非原子操作,这导致volatile 变量的运算在并发下一样是不安全的。运算操作符都是由成多个指令组成的比如 ++ 自增操作,由下面的字节码组成:

Stack=2, Locals=0, Args_size=0 
0: getstatic
3: iconst_1 
4: iadd 
5: putstatic

用volatile修饰 i ,然后进行自增:

public static volatile int i = 0;i++;

当把 i 的值取到栈顶时,volatile关键字保证了 i 的值在此时是正确的,但是在执行iconst_1iadd这些指令的时候,其他线程可能已经把 i 的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的 值同步回主内存中。volatitle关键字用于一写多读的情景,即一个操作线程,多个读的线程。

四、线程相关问题汇总

4.1 Runnable和Callable的区别

两者都需要调用Thread.start()启动线程;Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。

因为Callable的call方法提供Future类型的返回值,所以当你需要知道任务执行的结果时,Callable是个不错的选择。

Runnable

Callable

4.2 Callable如何获取返回值?

Callalble接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

public class Main {public static void main(String[] args) throws InterruptedException, ExecutionException {ExecutorService executor = Executors.newFixedThreadPool(2);//创建一个Callable,3秒后返回String类型Callable myCallable = new Callable() {@Overridepublic String call() throws Exception {Thread.sleep(3000);System.out.println("calld方法执行了");return "call方法返回值";}};System.out.println("提交任务之前 "+getStringDate());Future future = executor.submit(myCallable);System.out.println("提交任务之后,获取结果之前 "+getStringDate());System.out.println("获取返回值: "+future.get());System.out.println("获取到结果之后 "+getStringDate());}public static String getStringDate() {Date currentTime = new Date();SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss");String dateString = formatter.format(currentTime);return dateString;}}
}

结果:

4.3 get()方法的阻塞性:

在调用submit提交任务之后,主线程本来是继续运行了。但是运行到future.get()的时候就阻塞住了,一直等到任务执行完毕,拿到了返回的返回值,主线程才会继续运行。

因为调用get()方法时,任务还没有执行完,所以会一直等到任务完成,形成了阻塞。任务是在调用submit方法时就开始执行了,如果在调用get()方法时,任务已经执行完毕,那么就不会造成阻塞。

System.out.println("提交任务之前 "+getStringDate());
Future future = executor.submit(myCallable);
System.out.println("提交任务之后 "+getStringDate());
Thread.sleep(4000);
System.out.println("已经睡了4秒,开始获取结果 "+getStringDate());
System.out.println("获取返回值: "+future.get());
System.out.println("获取到结果之后 "+getStringDate());

因为睡了4秒,任务已经执行完毕,所以get方法立马就得到了结果。

submit两个任务时,总阻塞时间是最长的那个。例如,有两个任务,一个3秒,一个5秒:

Callable myCallable = new Callable() {@Overridepublic String call() throws Exception {Thread.sleep(5000);System.out.println("calld方法执行了");return "call方法返回值";}
};
Callable myCallable2 = new Callable() {@Overridepublic String call() throws Exception {Thread.sleep(3000);System.out.println("calld2方法执行了");return "call2方法返回值";}
};
System.out.println("提交任务之前 "+getStringDate());
Future future = executor.submit(myCallable);
Future future2 = executor.submit(myCallable2);
System.out.println("提交任务之后 "+getStringDate());
System.out.println("开始获取第一个返回值 "+getStringDate());
System.out.println("获取返回值: "+future.get());
System.out.println("获取第一个返回值结束,开始获取第二个返回值 "+getStringDate());
System.out.println("获取返回值2: "+future2.get());
System.out.println("获取第二个返回值结束 "+getStringDate());
提交任务之前 14:14:47
提交任务之后 14:14:48
开始获取第一个返回值 14:14:48
calld2方法执行了
calld方法执行了
获取返回值: call方法返回值
获取第一个返回值结束,开始获取第二个返回值 14:14:53
获取返回值2: call2方法返回值
获取第二个返回值结束 14:14:53

获取第一个结果阻塞了5秒,所以获取第二个结果立马就得到了。

4.4 submit(Runnable task):

因为Runnable是没有返回值的,所以如果submit一个Runnable的话,get得到的为null。

Runnable myRunnable = new Runnable() {@Overridepublic void run() {try {Thread.sleep(2000);System.out.println(Thread.currentThread().getName() + " run time: " + System.currentTimeMillis());} catch (InterruptedException e) {e.printStackTrace();}}
};Future future = executor.submit(myRunnable);
System.out.println("获取的返回值: "+future.get());
pool-1-thread-1 run time: 1493966762524
获取的返回值: null

4.5  submit(Runnable task, T result)

虽然submit传入Runnable不能直接返回内容,但是可以通过submit(Runnable task, T result)传入一个载体,通过这个载体获取返回值。这个其实不能算返回值了,是交给线程处理一下。

先新建一个载体类Data:

public static class Data {String name;String sex;public String getName() {return name;}public void setName(String name) {this.name = name;}public String getSex() {return sex;}public void setSex(String sex) {this.sex = sex;}
}

然后在Runnable的构造方法中传入:

static class MyThread implements Runnable {Data data;public MyThread(Data name) {this.data = name;}@Overridepublic void run() {try {Thread.sleep(2000);System.out.println("线程 执行:");data.setName("新名字");data.setSex("新性别");} catch (InterruptedException e) {e.printStackTrace();}}
}

然后调用:

Data data = new Data();
Future<Data> future = executor.submit(new MyThread(data), data);
System.out.println("返回的结果 name: " + future.get().getName()+", sex: "+future.get().getSex());
System.out.println("原来的Data name: " + data.getName()+", sex: "+data.getSex());
线程 执行:
返回的结果 name: 新名字, sex: 新性别
原来的Data name: 新名字, sex: 新性别

发现原来的data也变了。

4.6  get(long var1, TimeUnit var3)

前面都是用的get()方法获取返回值,那么因为这个方法是阻塞的,有时需要等很久。所以有时候需要设置超时时间。

get(long var1, TimeUnit var3)这个方法就是设置等待时间的。

如下面的任务需要5秒才能返回结果:

Callable myCallable = new Callable() {@Overridepublic String call() throws Exception {Thread.sleep(5000);return "我是结果";}
};
Future future1 = executor.submit(myCallable);
System.out.println("开始拿结果 "+getStringDate());
System.out.println("返回的结果是: "+future1.get()+ " "+getStringDate());
System.out.println("结束拿结果 "+getStringDate());
开始拿结果 16:00:43
返回的结果是: 我是结果 16:00:48
结束拿结果 16:00:48

现在要求最多等3秒,拿不到返回值就不要了,所以用get(long var1, TimeUnit var3)这个方法

方法的第一个参数是长整形数字,第二个参数是单位,跟线程池ThreadPoolExecutor的构造方法里一样的。

Future future1 = executor.submit(myCallable);
System.out.println("开始拿结果 "+getStringDate());
try {System.out.println("返回的结果是: "+future1.get(3, TimeUnit.SECONDS)+ " "+getStringDate());
} catch (TimeoutException e) {e.printStackTrace();System.out.println("超时了 "+getStringDate());
}
System.out.println("结束拿结果 "+getStringDate());

然后输出是

 

过了三秒就抛出超时异常了,主线程继续运行,不会再继续阻塞。

五、什么是守护线程?

在java多线程开发中,有两类线程,分别是User Thread(用户线程)和Daemon Thread(守护线程) 。

守护线程又被称为“服务进程”、“精灵线程”或“后台线程”,是指在程序运行时在后台提供一种通用服务的线程,比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。通俗点讲,任何一个守护线程都是整个JVM中所有非守护线程的“保姆”,守护线程,类似于操作系统里面是守护进程。

5.1 用户线程与守护线程的区别

JVM 程序在什么情况下能够正常退出?答:当 JVM 中不存在任何一个正在运行的非守护线程时,则 JVM 进程即会退出。

事实上,User Thread(用户线程)和Daemon Thread(守护线程)从本质上来说并没有什么区别,唯一的不同之处就在于虚拟机的离开:如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。

5.2 区分用户线程和守护线程

isDaemon( ),返回true为守护线程,反之为用户线程

5.3 手动设置守护线程

守护线程并非只有虚拟机内部可以提供,用户也可以手动将一个用户线程设定/转换为守护线程。

在调用start( )前调用对象的setDeamon(true)方法,若将参数改为false,则表示的是用户线程模式。

注意:当在一个守护线程中产生了其他线程,那么这些新产生的线程默认还是守护线程。

class ThreadDemo extends Thread{@Overridepublic void run() {System.out.println(Thread.currentThread().getName()+":begin");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName()+":end");}
}public class Test {public static void main(String[] args) {System.out.println("test3:begin");Thread t1 = new ThreadDemo();t1.setDaemon(true);t1.start();System.out.println("test3:end");}
}
//test3:begin
//test3:end
//Thread-0:begin

从运行结果中可以发现,没有输出Thread-0:end。因为在启动线程前将其设置为守护线程了,当程序中只有守护线程存在时,JVM是可以退出的,也就是说,当JVM中只有守护线程运行时,JVM会自动关闭。因此,当test3方法调用结束后,main线程将退出,此时线程t1还处于休眠状态没有运行结束,但是由于此时只有这个守护线程在运行,JVM将会关闭,因此不会输出Thread-0:end。

5.4 使用守护线程的注意事项

 (1)必须在线程运行前设置是否为守护线程

thread.setDaemon(true)必须在thread.start()之前设置。否则将引发IllegalThreadStateException异常。这意味着正在运行的常规线程不能设置为守护进程线程。

(2)守护线程创建的新线程也是守护线程

(3)守护线程不要去操作固有资源

并非所有用户线程都可以分配给守护线程进行服务,例如读写操作或计算逻辑。因为这个应用程序可能在DaemonThread有时间操作之前就退出了虚拟机。这意味着守护进程线程永远不应该访问固有的资源,例如文件和数据库,因为它可以在任何时候被中断,甚至在操作的中间。

5.5  守护线程优雅地停止用户线程

虽然java的Thread类里面,提供了很多让线程停止和销毁的方法,但早在jdk1.2版本就不推荐使用了。主要原因是强制停止线程容易造成死锁。对操作中的数据非常不友好。但守护线程很好地解决了这一问题。通过守护线程,可以优雅地停止用户线程。

public class DaemonTest1 {public static boolean flag = true;static class MyThread implements Runnable {@Overridepublic void run() {while (flag) {for (int i = 0; i < 5; i++) {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() +" " + (i+1));}}}}public static void main(String[] args) {Thread t1 = new Thread(new MyThread(), "用户线程");Thread t2 = new Thread(() -> {try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}flag = false;}, "守护线程!");t2.setDaemon(true);t1.start();t2.start();}
}
用户线程 1
用户线程 2
用户线程 3
用户线程 4
用户线程 5
用户线程 1
用户线程 2
用户线程 3
用户线程 4
用户线程 5

在main方法中,启动了两个线程,t1为用户线程,如果flag标志为true,则进行循环报数,每50毫秒报数1-5。t2为守护线程,100毫秒后,将flag标志置为false,也就让t1停止报数。当t1停止报数后,t1线程运行结束,由于没有其他线程t2守护线程运行结束。

在100毫秒的时间内,t1用户线程报了两轮数。然后整个线程运行结束。


http://www.mrgr.cn/news/82811.html

相关文章:

  • NLP CH3复习
  • 基于FPGA的出租车里程时间计费器
  • HTML5 滑动效果(Slide In/Out)详解
  • 探索 Vue.js 的动态样式与交互:一个有趣的样式调整应用
  • 【Python运维】使用Python与Docker进行高效的容器化应用管理
  • 26考研资料分享 百度网盘
  • 《异步编程之美》— 全栈修仙《Java 8 CompletableFuture 对比 ES6 Promise 以及Spring @Async》
  • 2024年AI图像生成热门模型回顾
  • 苍穹外卖 项目记录 day03
  • Requests聚焦爬虫-数据解析
  • 服务器双网卡NCCL通过交换机通信
  • 【学Rust开发CAD】2 创建第一个工作空间、项目及库
  • 【SpringSecurity】二、自定义页面前后端分离
  • 鸿蒙APP之从开发到发布的一点心得
  • 前端实现大文件上传(文件分片、文件hash、并发上传、断点续传、进度监控和错误处理,含nodejs)
  • 每日AIGC最新进展(80): 重庆大学提出多角色视频生成方法、Adobe提出大视角变化下的人类视频生成、字节跳动提出快速虚拟头像生成方法
  • 医学图像分析工具01:FreeSurfer || Recon -all 全流程MRI皮质表面重建
  • ISP图像调优流程
  • Unity中 Xlua使用整理(一)
  • 数组和指针
  • jenkins入门6 --拉取代码
  • 5G学习笔记之SNPN系列之网络选择
  • 在K8S上部署OceanBase的最佳实践
  • <OS 有关> DOS 批处理命令文件,用于创建 python 虚拟机,并进入虚拟机状态执行后继命令 判断虚拟机是否存在,在批处理文件中自定义 虚拟机名字
  • ffmpeg 常用命令
  • day01_ Java概述丶开发环境的搭建丶常用DOS命令