Java内存模型与线程
实现多线程我们知道可以继承Thread、实现Runnable接口等,但是为什么就实现呢?这篇文章就是解释多线程实现的底层原理。
一、主内存和工作内存
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。
线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
二、原子性、可见性、有序性(并发编程三要素)
2.1 原子性
原子性操作在执行时不会被其他线程干扰。
如果多个线程同时访问共享资源,原子性可以防止数据的不一致。
Java 中的原子性
-
原子性操作示例:
-
读取和写入基本数据类型(如
int
、float
)是原子性的。
-
-
非原子性操作:
-
复合操作(如
counter++
或counter = counter + 1
)是非原子性的。这些操作实际上包括三步:读取变量值、修改值、写回变量。
-
解决方案
-
使用 同步机制(如
synchronized
块或方法):1
2
3
synchronized
(lock) {
counter++;
}
-
使用 原子类(如
AtomicInteger
):1
2
AtomicInteger counter =
new
AtomicInteger();
counter.incrementAndGet();
2.2 可见性
可见性指的是一个线程对共享变量的修改对其他线程是可见的。
在多线程环境中,如果没有同步机制,一个线程对变量的修改可能不会立刻被其他线程看到(由于 CPU 缓存或编译优化)。
线程可能会一直使用自己 CPU 缓存中的值,而看不到其他线程更新后的值。
volatile修饰变量时其实就是使得这个变量具有可见性。无论变量是否被volatile修饰,Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。不同的是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
2.3 有序性
有序性指的是程序代码的执行顺序,通常来说,程序会按照代码编写的顺序执行,但编译器和处理器会为了优化性能进行 指令重排。
单线程中,指令重排不会影响程序的正确性。
多线程中,指令重排可能导致意想不到的结果,因为线程之间的执行顺序无法预测。
解决方案
-
使用 volatile:确保关键变量的修改不会被指令重排。
-
使用 synchronized 或 显式锁:同步代码块可以强制线程按照指定顺序执行。
三者关系与 happens-before
原则
-
原子性 和 可见性 是独立的,但有时需要结合使用才能实现正确的多线程行为。
-
有序性 通常需要通过
volatile
或同步机制来确保。 -
happens-before
是 Java 内存模型中定义的一种原则,用于规定线程间的操作顺序:-
一个线程对变量的写操作对另一个线程的读操作可见,必须满足
happens-before
原则。 -
如:
synchronized
、volatile
、线程启动/终止等操作会建立happens-before
关系。
-
Happens-Before原则用于定义在并发编程中不同操作之间的可见性和顺序性。如果操作A Happens-Before 操作B,意味着操作A的执行结果对操作B可见,即操作B能够看到操作A对共享变量的修改。
基本概念
Happens-Before原则定义了操作之间的顺序关系,确保在并发编程中,当一个操作的结果需要被另一个操作访问时,前者必须先执行。这有助于解决多线程环境下的内存可见性问题,确保线程之间的操作按照特定的顺序执行。
主要规则
- 程序顺序规则:在同一个线程中,前面的操作Happens-Before后面的操作。
- 监视器锁规则:同一个锁的unlock操作happen-before此锁的lock操作。
- volatile变量规则:对volatile变量的写操作Happens-Before此变量的读操作。
- 传递性规则:如果操作A Happens-Before 操作B,且操作B Happens-Before 操作C,则操作A Happens-Before 操作C。
- 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程终结规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。
-
线程中断规则:对一个线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生(即先中断才会被检测到中断状态)。如果没有这个规则的话,线程被中断之后,Thread.interrupted()无法检查到中断发生,外部依赖的程序就无法正常执行。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。如果先执行finalize(),初始化的后半部分就会出现问题,因为找不到该对象了。
应用场景和重要性
Happens-Before原则在并发编程中非常重要,它确保了在不同线程中对共享变量的访问和修改能够按照特定的顺序进行,从而避免了数据竞争和内存可见性问题。java设计者也考虑到这个问题,所以设计了happen-before原则
,只要符合其中的规则,就不用担心可见性问题。通过理解这些规则,开发者可以编写出更安全、更可靠的并发程序。
三、volatile
上面也大致讲了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_1、iadd这些指令的时候,其他线程可能已经把 i 的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的 i 值同步回主内存中。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用户线程报了两轮数。然后整个线程运行结束。