1. 认识线程
1.1 概念
1. 什么是线程/多线程(Thread)
线程就是一个 “执行流” ,每个线程都能执行自己的代码。
多线程是一种并发执行任务的方式,允许程序在多个线程之间切换,从而同时运行多个任务,提高程序的执行效率和响应速度。
- 并发执行:多个线程可以同时运行,执行不同的任务。
- 资源共享:线程可以共享进程的资源,如内存、文件句柄等。
- 提高响应速度:对于 GUI 应用或服务器应用,多线程可以提高用户体验。
2. 进程(process)和线程(Thread)的关系
进程:线程 = 1:m
- 一个线程一定属于一个进程;一个进程下可以有多个线程
- 一个进程下至少有一个线程,通常这个一开始就存在的线程,称为主线程,主线程和其他线程之间是完全平等的,没有如何特殊性
- 进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间
- 进程是资源分配的基本单位,线程是os进行调度的单位
- 由于进程把调度单位这一个职责让渡给线程了,所以,使得单纯进程的创建销毁适当简单
- 由于线程的创建和销毁不涉及资源分配、回收的问题,所以,通常理解,线程的创建/销毁成本要低于进程的成本
3、协程和线程的关系
协程
相比于进程,线程的上下文信息已经很小了,但是往往有些切换,有效操作的时间小于线程上下文切换的时间。也就是说,线程的上下文切换,还是浪费资源。
在此基础上,提出了协程的概念,也叫微线程。
协程简化了线程的上下文组成,只保留了程序调用堆栈和局部变量数据以及返回地址等。
协程非常类似子程序调用过程。
目前协程直接支持的编程语言不多。
协程和线程的关系
协程是基于线程的,所以协程切换不用切换线程上下文。也就是说,协程的调度中需要的资源是及其小的,几乎可以忽略不计。
协程是线程自己调度的,可以根据业务进行调度。
协程不需要线程并发的锁机制,不存在并发写读等冲突。
4. 为什么os要引入 Thread 概念
由于进程这一概念天生就是资源隔离的,所以进程之间进行数据通信是个高成本工作
但现实中,一个任务通常需要多个执行流一起配合工作完成,所以就需要一种方便数据通信的执行流概念,线程就承担了这个职责。
所以线程变成了独立执行流的承载概念,而进程退化成资源(不包含cup)分配的承载概念
5. Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念,操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户使 用(例如 Linux 的 pthread 库)。
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装。
是API为什么不直接调用,封装是封装了什么?
主要是操作系统线程还是有一些特性,和Java线程表现不一样,最典型的,比如说操作系统,一崩整个进程崩了,Java中就不是这样的,其次呢,封装就可以增加基本jvm对线程的控制嘛,具体有哪些,这个就太多了。
6. 并发、并行、串行
- 并发(Concurrency):并发是指在同一时间段内,有多个任务在执行,但在任意一个时刻,实际上只有一个任务在运行。这些任务在逻辑上是同时进行的,但通过操作系统的调度算法,在不同的时间片内交替执行,给人一种多个任务同时执行的错觉。
- 并行(Parallelism):并行是指在同一时刻,有多个任务在不同的处理器核心或不同的计算机上同时执行。与并发不同,并行是真正意义上的多个任务同时进行,每个任务都有自己独立的执行路径和资源,互不干扰。
- 串行:多个任务按照顺序依次执行,一个任务执行完成后,再开始执行下一个任务。在任何时刻,只有一个任务在执行,其他任务处于等待状态。
7. 阻塞和非阻塞
- 阻塞
-
- 定义:当一个操作执行时,如果当前线程会被挂起,直到该操作完成,那么这个操作就是阻塞的。在阻塞期间,线程不能执行其他任务,只能等待。
- 示例:当使用
InputStream
读取文件时,如果文件没有读取完,线程就会一直阻塞在读取操作上,直到读取完成。 - 应用场景:适用于需要确保操作完成后再继续后续步骤的情况,例如在初始化资源、读取关键配置文件时,确保资源正确初始化和配置读取完整,避免后续操作使用到不完整或未初始化的资源。
- 非阻塞
-
- 定义:非阻塞操作不会使当前线程挂起。即使操作尚未完成,线程也可以继续执行其他任务,而不是等待操作结束。通常通过轮询或回调机制来处理操作的结果。
- 示例:在使用非阻塞的网络套接字时,线程可以在发送或接收数据的操作未完成时,继续执行其他代码,而不会被阻塞。
- 应用场景:在处理多个并发任务且不希望一个任务的等待影响其他任务执行的场景中非常有用,如网络服务器中同时处理多个客户端连接请求,提高系统的并发处理能力和响应速度。
8. 同步和异步
- 同步
-
- 定义:同步操作是指调用方在调用一个方法或执行一个操作后,会一直等待该操作完成并返回结果,然后再继续执行后续的代码。调用方和被调用方之间是顺序执行的关系,就像串行执行一样,一个操作接着一个操作进行。
- 示例:在 Java 中,当主线程调用一个普通的方法时,主线程会阻塞等待该方法执行完毕返回结果后,才会继续执行下一行代码。
- 应用场景:适用于需要按照特定顺序执行操作,并且后续操作依赖于前面操作结果的情况,例如一系列的数据处理步骤,每个步骤的输出是下一个步骤的输入,确保数据的处理顺序和一致性。
- 异步
-
- 定义:异步操作则是调用方在发起一个操作后,不会等待操作完成,而是继续执行后续的代码。当操作完成时,会通过回调函数、事件通知或 Future 对象等方式来通知调用方获取结果。
- 示例:在 JavaScript 中,可以使用
setTimeout
函数设置一个异步任务,主线程不会等待setTimeout
中的回调函数执行,而是继续执行后面的代码,当延迟时间到达后,才会执行回调函数。 - 应用场景:常用于处理一些耗时较长且不希望阻塞主线程的操作,如网络请求、文件读取等。这样可以让主线程继续处理其他任务,提高用户体验和系统的响应性。例如在 Web 应用中,用户发起一个文件下载请求后,可以继续在页面上进行其他操作,而不是等待文件下载完成才可以进行下一步操作。
1.2 创建一个线程
1. java 线程在代码是如何体现
java.lang.Thread 类(包括其子类)的一个对象
2. 基本创建方法
1、通过继承Thread 类,并且重写run 方法
实例化该类对象 -> Thread 对象
public class Main {
static class MyThread extends Thread{
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
}
}
2、通过实现Runnable 接口,并且重写run方法
实例化Runnable 对象,利用Runnable 对象构建一个Thread
public class Main {
static class MyThreadTask implements Runnable{
@Override
public void run() {
System.out.println("我是子线程,实现Runnable接口");
}
}
public static void main(String[] args) {
MyThreadTask task = new MyThreadTask(); // 创建了一个任务对象
Thread t2 = new Thread(task); // 把 task 作为 Thread 的构造方法传入
t2.start();
}
}
3、其他变形
- 匿名内部类创建 Thread 子类对象
// 使用匿名类创建 Thread 子类对象
Thread t1 = new Thread() {
@Override public void run() {
System.out.println("使用匿名类创建 Thread 子类对象");
}
};
- 匿名内部类创建 Runnable 子类对象
// 使用匿名类创建 Runnable 子类对象
Thread t2 = new Thread(new Runnable() {
@Override public void run() {
System.out.println("使用匿名类创建 Runnable 子类对象");
}
});
- lambda 表达式创建 Runnable 子类对象
// 使用 lambda 表达式创建 Runnable 子类对象
Thread t3 = new Thread(() -> System.out.println("使用匿名类创建 Thread 子类对象"));
Thread t4 = new Thread(() -> {
System.out.println("使用匿名类创建 Thread 子类对象");
});
4、实现 Callable 接口(带返回值)
Callable
接口支持返回值和异常处理,比 Runnable
更强大。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "线程执行:" + Thread.currentThread().getName();
}
}
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
Thread t1 = new Thread(futureTask);
t1.start();
System.out.println(futureTask.get());
}
}
3. 启动线程
调用当前Thread 对象的start() 方法
注意:
- 一个已经调用过start()不能再调用start() 了再调用就会有异常发生,t.start() 只允许在”新建“ 状态下执行,执行两次,报IllegalThreadStateException 非法线程状态异常
- 不要调用成run(),如果调用run(),只是调用了一个类方法,并不是启动该线程
4. 如何理解t.start() 做了什么?
线程状态:新建 -> 就绪
t.start() 把线程的状态从新建变成了就绪状态,但并不分配cpu,只是该线程被加入到线程调度器的就绪队列中,等待被调度器选择分配cpu
从子线程进入就绪队列时,子线程和主线程地位就完全平等了,哪个线程被分配cpu都是随机的
5. 但大概率是主线程中的打印先执行,为什么?
因为子线程刚执行t.start(),立马被调度器分配cpu概率不大
6. 什么时候,子线程中的语句会先执行?
- 非常碰巧的在t.start()之后 sout(...)之前,发生了一次线程调度
- 主线程的状态运行–>就绪 主线程不再持有CPU。所以主线程的下一条语句不再执
- 调取时候,选中子线程调度 子线程的状态︰就绪–>运行,子线程持有了CPU,所以,执行到子线程的语句
7. 什么情况下会出现线程调度(开始选择一个新的线程分配cpu)
a. cpu 空闲
- 当前运行着的CPU执行结束了 运行->结束
- 当前运行着的CPU等待外部条件 运行->阻塞
- 当前运行着的CPU主动放弃 运行->就绪
b. 被调度器主动调用
- 高优先级线程抢占
- 时间片耗尽(最常见的情况)
在多线程中,明明代码是固定的,但会出现现象是随机的可能性,主要原因就是调度的随机性体现在线程的运行过程中
我们写的无论是Thread的子类还是 Runnable 的实现类,只是给线程启动的“程序”。所以,同一个程序,可以启动多个线程。
8. 线程 和 方法调用栈关系
每个线程都有自己独立的调用栈,debugger 中看到的每一行都是栈帧,保存的就是运行方法时的临时变量(主要是局部变量)
如图是主线程的栈(先进先出)
这些框出现的顺序符合FILO(使用栈去维护这些框)
栈:当前执行流的当前时刻(时间停止状态时)的状态框有哪些(现实方法的调用次序)
框:栈帧(frame)装的就是运行该方法时需要的一些临时数据(主要就是具备变量)
目前jvm中运行的线程
因为每个线程都是独立的执行流,所以A线程和B线程调用的方法没有任何关系,表现就是每个线程有自己独立的栈
2. Thread 类及常见方法
Thread 类是 JVM 用来管理线程的一个类
2.1 Thread 的常见构造方法
方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target,String name) | 使用Runnable对象创建线程对象,并命名 |
Thread(ThreadGroup group,Runnable target) | 线程可以被用来分组管理,分好的组即为线程组 |
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("线程名字");
Thread t4 = new Thread(new MyRunnable(), "线程名字");
2.2 Thread 的几个常见属性
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() 仅检查线程的中断状态,不会清除中断状态 |
- ID 是线程的唯一标识,不同线程不会重复,只能get,不能set
- 名称是各种调试工具用到,便于开发者看,jvm本身并不需要这个属性,没设置名字,默认Thread-0、Thread-1...,可以getName(),setName()
- 状态表示线程当前所处的一个情况,下面我们会进一步说明
- 优先级高的线程理论上来说更容易被调度到
- 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
- 是否存活,即简单的理解,为 run 方法是否运行结束了
- 线程的中断问题,下面我们进一步说明
1. 线程可以get/set自己的优先级
注意:这个优先级的设置,只是给JVM一些建议,不能强制让哪个线程先被调度。
2. 前台线程vs后台线程/精灵线程(daemon)/守护线程
后台线程一般是做一些支持工作的线程(JVM内部的管理线程)
前台线程一般是做一些有交互工作的 (我们创造的线程默认都是前台线程)
3. JVM进程什么时候才能退出
所有的前台线程都退出了,JVM进程就退出了
- 必须要求所有前台都退出,和主线程没关系
- 和后台线程没关系,即使后台线程还在工作,也正常退出
2.3 Thread 下几个常见的静态方法
1. 休眠Thread.sleep();
让线程休眠,默认毫秒
Thread.sleep(1000) == TimeUnit.SECOND.sleep(1) 让线程休眠1秒
从线程的状态的角度,调用sleep(?),就是让当前线程从 “运行”->“阻塞” ,当条件满足时(时间过去了) 线程从“阻塞”->"就绪" 这个间隔很短,基本对人类无感当线程被调度器选中时 开始接着之前的指令执行。
2. 得到当前引用对象Thread.currentThread();
Thread 引用,执行一个线程对象,执行的就是在哪个线程中调用的该方法,返回哪个线程对象,用于获取调用当前方法的线程信息。
3. 检查中断状态并清除Thread.interrupted();
- 若当前线程已被中断,该方法会返回
true
,同时将当前线程的中断状态重置为false
(即清除中断状态)。 - 若当前线程未被中断,该方法会返回
false
,并且不会对中断状态产生影响。
public class InterruptedExample {
public static void main(String[] args) {
// 创建一个线程
Thread thread = new Thread(() -> {
// 循环检查当前线程是否被中断
while (!Thread.interrupted()) {
System.out.println("线程正在运行...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// 当线程在睡眠时被中断,捕获中断异常
System.out.println("线程在睡眠时被中断");
// 再次检查中断状态,由于捕获异常时中断状态被清除,这里会返回 false
System.out.println("再次检查中断状态: " + Thread.interrupted());
}
}
System.out.println("线程已停止运行");
});
// 启动线程
thread.start();
try {
// 主线程休眠 3 秒
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 中断线程
thread.interrupt();
}
}
2.4 线程常用方法
方法 | 示例 |
start() | 启动线程 |
run() | 定义线程运行的逻辑 |
yield() | 暂时放弃CPU使用权 |
join() | 等待线程执行完成 |
interrupt() | 中断线程 |
isAlive() | 线程是否存活 |
2.5 中断一个线程-stop、-interrupt
1. 暴力停止 t.stop();
目前基本上已经不采用了。原因是直接杀掉B,不知道B是否把工作进行的如何了
2. 比较好的 t.interrupt();
方法 | 说明 |
public void interrupt() | 中断对象关联的线程,如果线程正在阻塞,则以异常方式通知,否则设置标志位 |
public static boolean interrupted() | 判断当前线程的中断标志位是否设置,调用后清除标志位 |
public boolean isInterrupted() | 判断对象关联的线程的标志位是否设置,调用后不清除标志位 |
A给B主动发一个信号,代表B已经停止了(发消息)
B在一段时间里,看到了停止信号之后,就可以主动,把手头的工作做到一个阶段完成,主动退出。
3. B如何感知到有人让它停止。
情况1: B正在正常执行代码,可以通过一个方法来判定 t.interrupted();
情况2: B可能正处于休眠状态(比如sleep、wait、join),意味着B无法立即执行Thread.interrupted()
此刻,JVM的处理方式是,以异常形式,通知B, InterruptedException
public class Main {
static class Mythread extends Thread{
@Override
public void run() {
while (true){
for (int i = 0; i < 1000; i++) {
System.out.println("我正在写代码");
}
if(interrupted()) {
System.out.println("休息前停下来");
break;
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// 当线程在睡眠时被中断,会抛出 InterruptedException
System.out.println("线程在睡眠时被中断");
// 再次检查中断状态,捕获异常后中断状态会被清除
System.out.println(Thread.interrupted());
break;
}
if(interrupted()) {
System.out.println("休息后停下来");
break;
}
}
System.out.println("线程停下来了");
}
}
public static void main(String[] args) {
Mythread t = new Mythread();
t.start();
Scanner scanner = new Scanner(System.in);
String s = scanner.next();
t.interrupt();
}
}
2.6 等待一个线程-join()
待一个线程完成它的工作后,才能进行自己的下一步工作
方法 | 说明 |
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等millis毫秒 |
public void join(long millis,int nanos) | 同理但是可以更高精度, |
public class Main {
private static class B extends Thread {
@Override
public void run() {
// 模拟 B 要做很久的工作
System.out.println("线程B开始工作");
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程B工作完毕");
}
}
private static void println(String msg) {
Date date = new Date();
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(format.format(date) + ": " + msg);
}
public static void main(String[] args) throws InterruptedException {
System.out.println("主线程A开始执行");
B b = new B();
b.start();
// 有 join 和没有 join 的区别
// 有join 时,b执行完了,才继续后面的指令
b.join();
System.out.println("主线程A执行完毕");
}
}
1. 利用join 完成并发对一个数组进行归并排序
import java.util.Arrays;
import java.util.Random;
// 众人拾柴火焰高
public class ConcurrentSort {
static class ArrayHelper {
public static long[] generateArray(int n) {
Random random = new Random(10000000);
long[] array = new long[n];
for (int i = 0; i < n; i++) {
array[i] = random.nextInt();
}
return array;
}
}
// 进行排序的线程
static class SortWorker extends Thread {
private final long[] array;
private final int fromIndex;
private final int toIndex;
// 利用构造方法,将待排序的数组区间情况,传入
// 对 array 的 [fromIndex, toIndex) 进行排序
SortWorker(long[] array, int fromIndex, int toIndex) {
this.array = array;
this.fromIndex = fromIndex;
this.toIndex = toIndex;
}
@Override
public void run() {
// 具体的排序过程,这里使用 Array.sort 做模拟
Arrays.sort(array, fromIndex, toIndex);
}
}
// 主线程记录排序耗时
public static void main(String[] args) throws InterruptedException {
long[] array = ArrayHelper.generateArray(4_000_0000);
// 分别是
// [0, 1000_0000)
// [1000_0001, 2000_0000)
// [2000_0001, 4000_0000)
// [3000_0001, 4000_0000)
long s = System.currentTimeMillis();
Thread t1 = new SortWorker(array, 0, 1000_0000);
t1.start();
Thread t2 = new SortWorker(array, 1000_0001, 2000_0000);
t2.start();
Thread t3 = new SortWorker(array, 2000_0001, 3000_0000);
t3.start();
Thread t4 = new SortWorker(array, 3000_0001, 4000_0000);
t4.start();
// 4 个线程开始分别的进行排序了
// 等待 4 个线程全部排序完毕
t1.join();
t2.join();
t3.join();
t4.join();
// 4 个线程一定全部结束了
// TODO:进行 4 路归并,将 4 个有序数组,归并成一个有序数组
long e = System.currentTimeMillis();
long elapsed = e - s;
System.out.println(elapsed);
}
}
2. 多核环境下,并发排序的耗时<串行排序的耗时(我们现在看到的现象)
单线程一定能跑在一个CPU (核)上,多线程意味着可能工作在多个核上(核亲和性)
3. 单核环境下,并发排序的耗时也能小于么?
即使在单核环境下,并发的耗时也可能较少。
本身,计算机下就有很多线程在等待分配CPU,比如,现在有100个线程。意味公平的情况下,我们的排序主线程,只会被分配1/100的时间。 当并发时,我们使用4个线程分别排序,除其他的99个之外,计算机中共有99+4 = 103个线程我们4个线程同属于一个进程,分给我们进程的时间占比4/103 >1 / 100。
所以,即使单核情况下,我们一个进程中的线程越多,被分到的时间片是越多的。
4. 那线程越多越好么?
当然不是
- 创建线程本身也不是没有代价的,创建销毁线程都需要时间成本。
在操作系统中,创建一个线程时,系统需要为其分配一系列的资源,比如栈空间用于存储线程的局部变量、函数调用信息等,还需要创建线程控制块(TCB)来管理线程的各种属性(如线程的状态、优先级、上下文信息等)。销毁线程时,也需要进行相应的资源回收和清理工作。这些操作都需要消耗时间和系统资源,所以线程数量越多,创建和销毁线程所带来的时间成本和资源开销也就越大。
- 即使理想情况下,不考虑其他耗时,CPU的利用率极限也就是100%,线程调度也需要耗时(OS从99个线程中挑一个的耗时和从9999个线程中挑一个的耗时不同。
操作系统需要在更多的线程中进行选择,需要更多的时间来比较线程的优先级、状态等信息,以确定哪个线程应该获得 CPU 时间,这就导致了线程调度的耗时增加。
CPU是公共资源,写程序的时候也是要考虑公德心的。作为开发者,在编写程序时不能无限制地创建大量线程,否则会过度占用 CPU 资源,影响其他程序的正常运行,甚至可能导致系统性能下降。
如果是好的OS系统,可能也会避免这个问题。例如,通过更高效的调度算法、线程分组管理等方式,在保证系统整体性能的前提下,尽量合理地分配 CPU 资源给各个线程。
5. 并发排序的耗时就一定小于串行的么?
不一定
串行的排序: t = t(排区间1) + t(排区间2)+ t(排区间3)+ t(排区间4)
并发的排序: t=4* t(创建线程)+t(排区间1)+ t(排区间2)+t(排区间3)+ t(排区间4)+4*t(销毁)
串行排序更快的情况
- 数据量小:当需要排序的数据量较少时,串行排序的优势较为明显。因为此时排序本身花费的时间较短,而并发排序中创建和销毁线程的开销相对较大,可能会超过并发带来的好处。例如,对几十到几百个数据元素进行排序,串行排序可能在短时间内就能完成,而并发排序由于线程创建和销毁的额外开销,反而会使总耗时增加。
- 任务切换开销大:在单核处理器环境下,并发排序依赖时间片轮转来实现多线程执行,这会导致频繁的任务切换。如果任务切换的开销较大,例如上下文切换需要保存和恢复大量的寄存器状态、内存页表等信息,那么串行排序避免了这些开销,会更高效。因为串行排序可以连续地使用 CPU 资源,直到排序任务完成,不会因任务切换而中断。
- 数据局部性要求高:串行排序通常对数据的访问具有较好的局部性,即相邻的数据元素在内存中是连续存储的,CPU 可以将这些数据预取到缓存中,从而减少内存访问时间。而并发排序中多个线程可能会随机访问不同的数据区间,破坏了数据的局部性,导致缓存命中率下降,增加了数据访问的时间。对于一些对缓存利用要求较高的排序算法,如插入排序、希尔排序等,在数据量较大且数据局部性较好的情况下,串行排序会更快。
并发排序更快的情况
- 数据量极大且多核环境:当面对大量的数据排序任务,并且系统具有多个处理器核心或多核处理器时,并发排序能够充分利用多核资源,将不同的数据区间分配到不同的核心上并行处理,从而显著提高排序效率。例如,在处理几十万甚至上百万条数据时,并发排序可以将数据分成多个部分,每个部分由一个线程在不同的核心上进行排序,大大缩短了整体的排序时间。而串行排序只能在一个核心上依次处理所有数据,耗时会很长。
- 排序任务独立性强:如果排序任务可以很容易地分解为多个相互独立的子任务,且子任务之间不需要频繁的同步和通信,那么并发排序会更有优势。例如,对一个大规模的数组进行排序,可以将数组分成若干个区间,每个区间的排序相互独立,并发排序可以同时对这些区间进行排序,提高整体效率。
- 硬件支持高效并发:一些硬件架构提供了对并发操作的特殊支持,如具有硬件多线程技术的处理器,能够在同一时间内处理多个线程,减少线程切换的开销。在这种情况下,并发排序能够更好地利用硬件特性,发挥出比串行排序更高的性能。
2.7 线程让出CPU-yield
线程从运行->就绪状态,随时可以继续被调度回CPU。
yield主要用于执行一些耗时较久的计算任务时,为让防止计算机处于“卡顿”的现象,时不时的让出一些CPU资源,给OS内的其他进程。
Thread.yield()
方法的作用是暗示当前线程愿意让出 CPU 资源,给其他具有相同优先级的线程执行机会。不过,这仅仅是一个暗示,操作系统的线程调度器不一定会遵循这个暗示。也就是说,调用 Thread.yield()
之后,当前线程可能会立即继续执行,也可能会暂停执行,让其他线程获得执行机会。
public class Main {
static class MyThread extends Thread {
private final String name;
public MyThread(String name) {
this.name = name;
}
@Override
public void run() {
while (true){
if(name.equals("张三")){
Thread.yield();
}
System.out.println("我是"+name);
}
}
}
public static void main(String[] args) {
MyThread zs = new MyThread("张三");
MyThread ls = new MyThread("李四");
zs.start();
ls.start();
}
}
3. 线程的状态
3.1 线程的所有状态
线程的状态是一个枚举类型 Thread.State
public class Main {
public static void main (String[]args){
for (Thread.State state : Thread.State.values()) {
//NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
System.out.println(state);
}
}
}
线程的生命周期主要包括以下几个阶段:
- 新建(New):线程对象被创建,但未启动。
- 就绪(Runnable):调用
start()
方法,等待 CPU 调度。 - 运行(Running):CPU 调度后开始执行
run()
方法。 - 阻塞(Blocked/Waiting):线程在等待资源时进入该状态。
- 终止(Terminated):线程执行完成或被中断
1. NEW(新建状态)
当创建一个 Thread
对象,但还未调用其 start()
方法时,线程处于新建状态。此时,线程对象已被创建,但还未被操作系统调度执行。
Thread thread = new Thread(() -> {
System.out.println("线程正在执行");
});
// 此时线程处于 NEW 状态
System.out.println(thread.getState());
2. RUNNABLE(可运行状态):
就绪+运行
可运行状态涵盖了两种情况。一是线程已经调用了 start()
方法,正在 Java 虚拟机中运行;二是线程已准备好运行,正在等待操作系统的 CPU 时间片。在 Java 中,RUNNABLE
状态将操作系统层面的 “就绪” 和 “运行” 状态合并了。
Thread thread = new Thread(() -> {
System.out.println("线程正在执行");
});
thread.start();
// 此时线程处于 RUNNABLE 状态
System.out.println(thread.getState());
3. BLOCKED(阻塞状态)
排队等着其他事情(阻塞)
当线程在等待获取一个排他锁(如 synchronized
块或方法),而该锁当前被其他线程持有时,线程会进入阻塞状态。线程会一直阻塞,直到获取到锁为止。
public class BlockedExample {
private static final Object lock = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("线程 t2 获取到锁");
}
});
t1.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
// 此时线程 t2 可能处于 BLOCKED 状态
System.out.println(t2.getState());
}
}
4. WAITING(等待状态)
排队等着其他事情(阻塞)
当线程调用以下方法时,会进入等待状态,并且会一直等待,直到其他线程调用特定方法来唤醒它:
Object.wait()
:调用该方法的线程会释放对象的锁,并进入等待状态,直到其他线程调用该对象的notify()
或notifyAll()
方法。Thread.join()
:当前线程会等待调用join()
方法的线程执行完毕。LockSupport.park()
:线程会进入等待状态,直到其他线程调用LockSupport.unpark(Thread thread)
方法。
public class WaitingExample {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此时线程 t2 可能处于 WAITING 状态
System.out.println(t2.getState());
}
}
5. TIMED_WAITING(定时等待状态)
排队等着其他事情(阻塞)
与等待状态类似,但定时等待状态有一个明确的等待时间。线程调用以下方法会进入定时等待状态,当等待时间结束后,线程会自动唤醒:
Thread.sleep(long millis)
:线程会暂停执行指定的毫秒数。Object.wait(long timeout)
:线程会释放对象的锁,并等待指定的毫秒数,直到其他线程调用该对象的notify()
或notifyAll()
方法,或者等待时间结束。Thread.join(long millis)
:当前线程会等待调用join()
方法的线程执行完毕,或者等待指定的毫秒数。LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long deadline)
:线程会进入定时等待状态,直到指定的时间结束或其他线程调用LockSupport.unpark(Thread thread)
方法。
public class TimedWaitingExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此时线程可能处于 TIMED_WAITING 状态
System.out.println(thread.getState());
}
}
6. TERMINATED(终止状态)
工作完成了
当线程的 run()
方法执行完毕,或者因为异常而终止时,线程进入终止状态。处于终止状态的线程已经结束了其生命周期,不能再次启动。
Thread thread = new Thread(() -> {
System.out.println("线程正在执行");
});
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 此时线程处于 TERMINATED 状态
System.out.println(thread.getState());
3.2 线程状态和状态转移的意义
在Java代码中看到的线程状态(只能获取不能设置,状态的变更是JVM控制的)
3.3 观察线程的状态和转移 jconsole工具
使用jconsole工具
C:\Program Files\Java\jdk1.8.0_131\bin
4. JVM下的内存区域划分
1 为什么每个线程都得有自己的程序计数器
每个线程都是独立的执行流,下一条要执行的指令和其他线程无关。
2 为什么每个线程都得有自己的栈
每个线程都是独立的执行流,有各自调用的方法链,有各自要处理的临时数据,所以栈也是独一份的。
5. 线程安全
5.1 线程安全的概念
代码的运行结果应该是100%符合预期。在多线程环境下,代码、函数或数据结构等能够正确地执行和维护其状态,而不会出现数据不一致、错误或其他不可预期的结果
线程安全的级别:
(1)不可变
像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用
(2)绝对线程安全
绝对线程安全的类在任何情况下都能保证线程安全,调用者无需采取额外的同步措施。不过,实现绝对线程安全通常需要付出较大的性能代价。Java中CopyOnWriteArrayList、CopyOnWriteArraySet在大多数常见操作上是线程安全的,但在某些复杂的操作序列中,如果需要保证操作的原子性,仍然可能需要额外的同步。例如,在对 CopyOnWriteArrayList
进行先检查后操作(check - then - act)的复合操作时,就可能需要调用者自己进行同步处理
(3)相对线程安全
相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制。
(4)线程非安全
ArrayList、LinkedList、HashMap等都是线程非安全的类
5.2 线程不安全的原因
1. 修改共享数据
1.站在开发者的角度
1)多个线程之间操作同一块数据了(共享数据)——不仅仅是内存数据
2)至少有一个线程在修改这块共享数据
多个线程中至少有一个对共享数据做修改(写)操作
即使在多线程的代码中,哪些情况下不需要考虑线程安全问题?
1.几个线程之间互相没有任何数据共享的情况下,天生是线程安全的;
2.几个线程之间即使有共享数据,但都是做读操作,没有写操作时,也是天生线程安全的。
2.系统角度解释
前置知识:
1. java代码(高级语言)中的一条语句,很可能对应的多条指令
r++实质就是r = r + 1
变成指令动作:
- 从内存中(r代表的内存区域)把数据加载到寄存器中 LOAD_A
- 完成数据加1的操作 ADD 1
- 把寄存器中的值,写回到内存中(r代表的内存区域) STORE_A
2. 线程调度是可能发生在任意时刻的,但是不会切割指令(一条指令只有执行完/完全没有执行两种可能)
3. 原子性
程序员的预期是r++ 或者r-- 是一个原子性的操作(全部完成or全部没完成)
但实际执行起来,保证不了原子性,所以会出错。
原子性被破坏是线程不安全的最常见的原因!!
4. 可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到
前置知识: CPU中为了提升数据获取速度,一般在CPU中设置缓存(Cache)
指令的执行速度>>内存的读写速度
线程的所有的数据操作(读/写)必须:
1.从主内存加载到工作内存中
2.在工作内存中进行处理 ...允许在工作内存中处理很久
3.完成最终的处理之后,再把数据同步回主内存
1.把r从主内存->当前线程的工作内存中
⒉循环r++,完成1000次(在工作内存中完成)中间允许同步回主内存
3.在1000放回r(主内存)
内存可见性: 一个线程对数据的操作,很可能其他线程的无法感知的。甚至,某些情况下,会被优化成完全看不到的结果!
5. 代码重排序
所谓的重排序,就是指:执行的指令不和书写指令并不一致。
5.3 线程不安全问题
1.所谓什么是线程安全?
1.程序的线程安全:运行结果100%符合预期
2.Java语境下,经常说某个类、对象是线程安全的: 这个类、对象的代码中已经考虑了处理多线程的问题了,如果只是“简单”使用,可以不考虑线程安全的问题。 ArrayList就不是线程安全的。——ArrayList实现中,完全没考虑过线程安全的任何问题。 无法直接使用在多线程环境(多个线程同时操作同一个ArrayList)
2.作为程序员如何考虑线程安全的问题?
1.尽可能让几个线程之间不做数据共享,各干各的。就不需要考虑线程安全问题了
2.如果非要有共享操作,尽可能不去修改,而是只读操作 static final int COUNT =..;即使多个线程同时使用这个COUNT也无所谓的
3.一定会出现线程问题了,问题的原因从系统角度讲:
1.原子性被破坏了
2.由于内存可见性问题,导致某些线程读取到“脏(dirty) "
3.由于代码重排序导致的线程之间关于数据的配合出问题了 所以,接下来需要学习一些机制,目标和JVM和沟通,避免上述问题的发生
3. 线程安全的类和线程不安全的类
线程不安全: ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet、StrinaBuilder
线程安全: Vector、Stack、Dictionary、StringBuffer,
它们的方法通常使用了 synchronized
关键字进行同步,确保在同一时刻只有一个线程能够访问和修改这些类的实例。但存在设计上的性能问题,它们的同步机制是粗粒度的,即在方法级别上进行同步,这可能会导致性能问题。当多个线程同时访问这些类的不同方法时,即使这些方法之间没有数据竞争,也会因为同步而产生阻塞,降低了并发性能。例如,一个线程调用 Vector
的 get
方法获取元素,另一个线程调用 add
方法添加元素,由于 Vector
的方法都是同步的,这两个线程也不能同时执行,尽管它们操作的是不同的元素,没有数据冲突。
可以用 java.util.concurrent
包中的并发集合类来替代它们。例如,ConcurrentHashMap
可以替代 Hashtable
,它采用了更细粒度的锁机制,允许多个线程同时访问不同的桶,提高了并发性能;CopyOnWriteArrayList
可以替代 Vector
,它在写入时会创建一个新的数组,避免了对整个列表的锁定,适用于读多写少的场景。
线程安全的集合:ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue
4. 最常见的违反原子性的场景?
1.read-write场景
i++;
array[size] = e;
size++;
i++
:
在 Java 中,i++
操作看似简单,实际上它并不是原子操作。i++
的操作过程分为三步:首先读取变量i
的值,然后对读取的值进行加 1 操作,最后将加 1 后的值写回到变量i
中。在多线程环境下,如果多个线程同时执行i++
操作,就可能出现问题。例如,线程 A 读取了i
的值为 5,此时线程 B 也读取了i
的值为 5,接着线程 A 对值加 1 并写回,i
的值变为 6;然后线程 B 也进行加 1 并写回,i
的值还是 6,这就导致了数据的不一致,因为实际上应该执行了两次加 1 操作,i
的值应该是 7。所以i++
这种 read-write(先读再写)的操作在多线程环境下违反了原子性。array[size] = e; size++;
:
这两行代码也不是原子操作。第一行代码是将元素e
赋值到数组array
中索引为size
的位置,第二行代码是对size
进行自增操作。在多线程环境下,假设线程 A 执行到array[size] = e;
时,获取了当前的size
值并将元素e
赋值到相应位置,但还没来得及执行size++;
,此时线程 B 也执行到这部分代码,获取了相同的size
值并进行赋值操作,这样就会导致数组中一个位置被重复赋值,然后两个线程再分别执行size++
,size
的值就会出现错误的增长,最终破坏了数据的完整性和操作的原子性。
2.check-update场景
if (a == 10) { a = ...; }
它先检查变量 a
的值是否等于 10,然后如果满足条件就对 a
进行更新操作。在多线程环境下,当线程 A 执行到 if (a == 10)
时,判断 a
的值确实为 10,但在它还没来得及执行 a = ...;
这部分更新操作时,线程 B 可能已经修改了 a
的值。当线程 A 再继续执行 a = ...;
时,就可能基于一个已经过时的条件(a
不再是 10)进行了错误的更新操作,这就违反了原子性,因为检查和更新这两个步骤应该作为一个整体原子操作来执行,以保证数据的一致性和正确性。
6. synchronized 锁
同步锁/monitor锁(监视器锁)
6.1 synchronized 的特性
1. 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
2. 刷新内存
synchronized 的工作过程:
1. 获得互斥锁
2. 从主内存拷贝变量的最新副本到工作的内存
3. 执行代码
4. 将更改后的共享变量的值刷新到主内存
5. 释放互斥锁
3. 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
6.2 synchronized 使用示例
public class Main {
static Object o = new Object();
static class MyThread1 extends Thread{
@Override
public void run() {
synchronized(o) {
for (int i = 0; i < 100000; i++) {
System.out.println("张三");
}
}
}
}
static class MyThread2 extends Thread{
@Override
public void run() {
synchronized(o) {
for (int i = 0; i < 100000; i++) {
System.out.println("李四");
}
}
}
}
public static void main(String[] args) {
MyThread1 t1 = new MyThread1();
MyThread2 t2 = new MyThread2();
t1.start();
t2.start();
}
}
synchronized void method(){}//使用 synchronized 修饰方法
等价于
void method(){
synchronized(this){//使用 synchronized 代码块
}
}
二者仅粒度不同,使用 synchronized 代码块的方式更加灵活,可以精确控制需要同步的代码范围。
例如,在 method 方法中,只有 synchronized (this) 代码块内的代码是同步的,
块外的代码可以被多个线程同时执行。
而使用 synchronized 修饰方法时,整个方法体都是同步的,粒度相对较粗。
以上锁的是对象的引用
比如多个线程操作同一对象的该方法就可以避免多线程问题
static synchronized void mehtod(){}
等价于
staitc void method(){
synchronized(类.class){
}
}
以上锁的是加载的类
锁理论上,就是一段数据(一段被多个线程之间共享的数据)
6.3 正确使用 synchronized
加锁操作使得互斥(synchronized和我们一起配合(我们需要正确地使用synchronized) )
1.保证了临界区的原子性
加锁粒度(临界区的大小)
//加锁力度较细
for(int i=0;i<10;i++){
synchronized(Example.class){
num++;
}
}
//加锁力度较粗
synchronized(Example.class){
for(int i=0;i<10;i++){
num++;
}
}
2. synchronized在有限程度上可以保证内存可见性
3. synchronized 也可以给代码重排序增加一定的约束
S1、S2、S3;加锁;S4、S5、S6;解锁;S7、S8、S9;
S1、S2、S3之间不做保证 S4、S5、S6不会被重排序到加锁之前,解锁之后
S4、S5、S6之间不做保证 S1、S2、S3不会到加锁之后的
S7、S8、S9之间不做保证 S7、S8、S9不会到加锁之前的
- 整体执行顺序约束:
synchronized
代码块形成了一个 happens - before 关系。即加锁操作 happens - before 解锁操作,解锁操作 happens - before 后续对同一锁的加锁操作。这保证了在synchronized
代码块内的操作,对于其他线程在获取到相同锁之后是可见的,也就是保证了S4、S5、S6
不会被重排序到加锁之前或解锁之后。 - 代码块内和代码块外的重排序情况
-
S1、S2、S3
之间以及与synchronized
代码块的关系:S1、S2、S3
是在synchronized
代码块之前的代码,它们之间的执行顺序不做保证,即可能会发生重排序。同时,由于synchronized
的约束,它们不会被重排序到加锁之后,因为如果S1、S2、S3
被重排序到加锁之后,就会破坏synchronized
所建立的 happens - before 关系,导致synchronized
代码块之前的操作对synchronized
代码块内的操作可见性出现问题。S4、S5、S6
之间的关系:S4、S5、S6
是synchronized
代码块内的代码,它们之间的执行顺序也不做保证,同样可能会发生重排序。但无论如何重排序,它们都不会被移动到加锁之前或解锁之后,以确保synchronized
代码块作为一个整体的原子性和可见性。S7、S8、S9
之间以及与synchronized
代码块的关系:S7、S8、S9
是在synchronized
代码块之后的代码,它们之间的执行顺序不做保证。并且由于synchronized
的约束,它们不会被重排序到加锁之前,否则会导致synchronized
代码块之后的操作先于synchronized
代码块内的操作执行,破坏了程序的逻辑和可见性规则。
public class SynchronizedReorderingExample {
private int x = 0;
private int y = 0;
private final Object lock = new Object();
public void method() {
// S1、S2、S3
x = 1;
y = 2;
System.out.println("x and y initialized");
// 加锁
synchronized (lock) {
// S4、S5、S6
x += 10;
y += 20;
System.out.println("x and y updated in synchronized block");
}
// 解锁
// S7、S8、S9
x *= 2;
y *= 3;
System.out.println("x and y further processed");
}
}
在 method
方法中,x = 1
和 y = 2
这两个操作(对应 S1、S2、S3
)可能会重排序,但不会重排序到 synchronized
代码块内。synchronized
代码块内的 x += 10
和 y += 20
(对应 S4、S5、S6
)也可能重排序,但不会超出 synchronized
代码块的范围。而 x *= 2
和 y *= 3
(对应 S7、S8、S9
)同样可能重排序,但不会跑到 synchronized
代码块之前执行。
重排序举例:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
// 这一行代码可能会发生重排序
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
Singleton singleton1 = Singleton.getInstance();
System.out.println("Thread 1 got instance: " + singleton1);
});
Thread t2 = new Thread(() -> {
Singleton singleton2 = Singleton.getInstance();
System.out.println("Thread 2 got instance: " + singleton2);
});
t1.start();
t2.start();
}
}
instance = new Singleton();
的执行过程:这行代码在实际执行时会分为以下三个步骤:
-
- 分配内存空间:为
Singleton
对象分配一块内存。 - 初始化对象:在分配的内存空间中对
Singleton
对象进行初始化操作。 - 将引用指向对象:将
instance
引用指向分配好内存并初始化好的对象。
- 分配内存空间:为
- 重排序的可能性:在没有使用合适的同步机制时,编译器和处理器可能会对上述三个步骤进行重排序,将步骤 2 和步骤 3 的顺序颠倒。也就是说,先将
instance
引用指向分配的内存空间,然后再进行对象的初始化操作。
重排序导致的问题
- 假设线程 1 进入
getInstance
方法,执行instance = new Singleton();
时发生了重排序,先完成了步骤 3(将instance
引用指向内存空间),但还未完成步骤 2(对象初始化)。 - 此时线程 2 进入
getInstance
方法,执行第一个if (instance == null)
检查,由于instance
已经指向了一块内存空间,所以该检查结果为false
,线程 2 会直接返回instance
。 - 但实际上,
instance
所指向的对象还未完成初始化,线程 2 在使用这个未初始化的对象时就会出现问题。
解决方案:
volatile 关键字可以保证 instance 变量的写操作 happens - before 后续对该变量的读操作,
从而禁止了指令重排序,确保线程安全。
public class Singleton {
// 使用 volatile 关键字禁止重排序
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
Singleton singleton1 = Singleton.getInstance();
System.out.println("Thread 1 got instance: " + singleton1);
});
Thread t2 = new Thread(() -> {
Singleton singleton2 = Singleton.getInstance();
System.out.println("Thread 2 got instance: " + singleton2);
});
t1.start();
t2.start();
}
}
public class SynchronizedSingleton {
private static SynchronizedSingleton instance;
private SynchronizedSingleton() {}
// 使用 synchronized 修饰整个方法
public static synchronized SynchronizedSingleton getInstance() {
if (instance == null) {
instance = new SynchronizedSingleton();
}
return instance;
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
SynchronizedSingleton singleton1 = SynchronizedSingleton.getInstance();
System.out.println("Thread 1 got instance: " + singleton1);
});
Thread t2 = new Thread(() -> {
SynchronizedSingleton singleton2 = SynchronizedSingleton.getInstance();
System.out.println("Thread 2 got instance: " + singleton2);
});
t1.start();
t2.start();
}
}
在这个例子中,getInstance 方法被 synchronized 关键字修饰。这意味着在同一时刻,只有一个线程能够进入该方法执行。
当一个线程进入 getInstance 方法时,会检查 instance 是否为 null,如果是,则创建一个新的 SynchronizedSingleton 对象并赋值给 instance。由于同一时刻只有一个线程能进入该方法,所以不会出现多个线程同时创建对象的情况,也就保证了单例的线程安全。
关于重排序问题,由于 synchronized 关键字保证了代码块的原子性和可见性,instance = new SynchronizedSingleton(); 这一操作在加锁的代码块内完成,其他线程只有在当前线程释放锁之后才能进入该方法,因此不会受到重排序的影响。
优点:实现简单,能有效保证线程安全,避免了重排序带来的问题。
缺点:每次调用 getInstance 方法都需要获取锁,即使 instance 已经被创建,这会带来一定的性能开销。
6.4 正确使用JUC包下的Lock锁
synchronized是一种非常早期就存在的锁
后期重新进行过一波设计,以类、对象的形式给我们使用(不在语言层面)java.util.concurrenJUC包 <--现代写java并发编程尽量使用这个包下提供的工具
public class Main {
static int r = 0;
static class MyThread1 extends Thread{
private Lock o;
MyThread1(){}
MyThread1(Lock o){
this.o = o;
}
@Override
public void run() {
o.lock();
try {
for (int i = 0; i < 1000_0000; i++) {
r++;
}
}
finally {
o.unlock();
}
}
}
static class MyThread2 extends Thread{
private Lock o;
MyThread2(){}
MyThread2(Lock o){
this.o = o;
}
@Override
public void run() {
o.lock();
try {
for (int i = 0; i < 1000_0000; i++) {
r--;
}
}
finally {
o.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
Lock o = new ReentrantLock();
MyThread1 t1 = new MyThread1(o);
MyThread2 t2 = new MyThread2(o);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(r);
}
}
7. volatile 关键字
修饰变量,JVM中线程要读变量,每次从主内存读,写入,保证写回主内存。
主要作用:
1、使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据
2、禁止指令重排序
7.1 保证变量的可见性
在多线程环境下,每个线程都有自己的工作内存,线程对变量的操作通常是先从主内存将变量读取到工作内存,然后在工作内存中对变量进行修改,之后再将修改后的值写回到主内存。这样就可能出现一个线程对变量的修改,其他线程不能及时看到的情况,即变量的可见性问题。
当一个变量被 volatile
修饰后,对该变量的写操作会立即刷新到主内存,而读操作会直接从主内存读取最新值。这就保证了一个线程对 volatile
变量的修改,能立即被其他线程看到。
public class VolatileVisibilityExample {
// 使用 volatile 修饰变量
private static volatile boolean flag = false;
public static void main(String[] args) {
// 启动一个线程修改 flag 的值
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("线程修改 flag 为 true");
}).start();
// 主线程不断检查 flag 的值
while (!flag) {
// 空循环等待
}
System.out.println("主线程检测到 flag 变为 true");
}
}
在上述代码中,flag
变量被 volatile
修饰,当子线程修改 flag
的值为 true
后,主线程能立即感知到 flag
的变化,从而跳出循环。
7.2 禁止指令重排序
对指令重排序是指编译器和处理器为了提高程序的执行性能,会对指令的执行顺序进行重新排列。在单线程环境下,指令重排序不会影响程序的最终结果,但在多线程环境下,可能会导致程序出现错误。
volatile
关键字可以禁止指令重排序,保证代码的执行顺序与编写顺序一致。在 Java 内存模型(JMM)中,对 volatile
变量的写操作会在写操作之前插入一个内存屏障,禁止写操作之前的指令重排序到写操作之后;对 volatile
变量的读操作会在读操作之后插入一个内存屏障,禁止读操作之后的指令重排序到读操作之前。
public class VolatileReorderingExample {
private static volatile int a = 0;
private static volatile boolean flag = false;
public static void writer() {
a = 1; // 操作 1
flag = true; // 操作 2
}
public static void reader() {
if (flag) { // 操作 3
int i = a; // 操作 4
System.out.println(i);
}
}
public static void main(String[] args) {
// 这里省略了多线程启动和调用 writer、reader 方法的代码
}
}
在上述代码中,由于 flag
被 volatile
修饰,操作 1 和操作 2 不会发生重排序,操作 3 和操作 4 也不会发生重排序,保证了多线程环境下程序的正确性。
7.3 不保证原子性
需要注意的是,volatile
关键字不保证变量操作的原子性。例如,对于 i++
这种复合操作,即使 i
被 volatile
修饰,它也不是原子操作,在多线程环境下仍然可能会出现数据不一致的问题。如果需要保证原子性,可以使用 synchronized
关键字或者 Atomic
类。
综上所述,volatile
关键字主要用于保证变量的可见性和禁止指令重排序,在多线程编程中可以解决一些由于可见性和指令重排序导致的问题。
8、Lock
Lock
是一个接口,它位于 java.util.concurrent.locks
包下,为实现线程同步提供了比 synchronized
关键字更灵活、更强大的机制。
基本概念
Lock
接口定义了一系列用于获取锁和释放锁的方法,通过这些方法可以手动控制锁的获取和释放,从而实现更细粒度的并发控制。与 synchronized
关键字不同,Lock
锁需要显式地获取和释放锁,这使得在复杂的并发场景下可以更灵活地控制锁的行为。
与 synchronized
的对比
- 灵活性:
synchronized
是 Java 中的内置锁,使用起来比较简单,但是它的锁获取和释放是隐式的,不够灵活。而Lock
锁可以手动控制锁的获取和释放,例如可以尝试获取锁一段时间,如果在这段时间内没有获取到锁,可以选择放弃,避免线程长时间阻塞。 - 可中断性:
synchronized
关键字获取锁的过程是不可中断的,一旦线程进入同步块,就会一直持有锁,直到同步块执行完毕或者抛出异常。而Lock
锁提供了可中断的锁获取方式,线程在获取锁的过程中可以被其他线程中断。 - 公平性:
synchronized
是非公平锁,即线程获取锁的顺序是不确定的,可能会导致某些线程长时间得不到锁。而Lock
锁可以实现公平锁,保证线程按照请求锁的顺序依次获取锁。
常用实现类
1. ReentrantLock
ReentrantLock
是 Lock
接口的一个常用实现类,它是可重入锁,即同一个线程可以多次获取同一把锁,而不会出现死锁的情况。ReentrantLock
支持公平锁和非公平锁两种模式,默认是非公平锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
// 获取锁
lock.lock();
try {
count++;
System.out.println(Thread.currentThread().getName() + " 增加后 count 的值为: " + count);
} finally {
// 释放锁
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
// 创建线程 1
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
example.increment();
}
});
// 创建线程 2
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
example.increment();
}
});
// 启动线程
thread1.start();
thread2.start();
}
}
ReentrantLock 用于保证 increment() 方法的线程安全。
在 increment() 方法中,通过 lock.lock() 获取锁,
在 finally 块中使用 lock.unlock() 释放锁,确保无论是否发生异常,锁都会被释放。
2. ReentrantReadWriteLock
ReentrantReadWriteLock
是一个读写锁,它将锁分为读锁和写锁。多个线程可以同时获取读锁,但是在获取写锁时,其他线程不能获取读锁或写锁。读写锁适用于读多写少的场景,可以提高并发性能。
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockExample {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
private int value = 0;
public int readValue() {
// 获取读锁
readLock.lock();
try {
return value;
} finally {
// 释放读锁
readLock.unlock();
}
}
public void writeValue(int newValue) {
// 获取写锁
writeLock.lock();
try {
value = newValue;
System.out.println(Thread.currentThread().getName() + " 写入值: " + value);
} finally {
// 释放写锁
writeLock.unlock();
}
}
public static void main(String[] args) {
ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();
// 创建读线程
Thread readThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 读取值: " + example.readValue());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 创建写线程
Thread writeThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
example.writeValue(i);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 启动线程
readThread.start();
writeThread.start();
}
}
ReentrantReadWriteLock 用于实现读写分离。
多个线程可以同时获取读锁进行读操作,
但是在进行写操作时,需要获取写锁,此时其他线程不能获取读锁或写锁。
9. wait 和 notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。
1.wait()和notify()方法是属于Object类的,Java 中的对象都带有这两个方法
⒉.要使用wait和notify,必须首先对“对象”进行synchronized加锁
1 wait()方法
wait 做的事情
- 使当前执行代码的线程进行等待. (把线程放到等待队列中)
- 释放当前的锁
- 满足一定条件时被唤醒, 重新尝试获取这个锁
wait 结束等待的条件
- 其他线程调用该对象的 notify 方法.
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
- 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
2 notify()方法
notify 方法是唤醒等待的线程.
- 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其 它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
- 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
- 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行 完,也就是退出同步代码块之后才会释放对象锁。
import java.util.concurrent.TimeUnit;
public class Demo1 {
static class MyThread extends Thread {
private Object o;
MyThread(Object o) {
this.o = o;
}
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o) {
System.out.println("唤醒主线程");
o.notify();
}
}
}
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
synchronized (o) {
MyThread t = new MyThread(o);
t.start();
o.wait(); // 1. wait会自己释放锁 2. 等待.. 3. 再加锁
System.out.println("永远不会到达");
}
}
}
3 notifyAll()方法
notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程
import java.util.concurrent.TimeUnit;
public class Demo3 {
static Object o = new Object();
static class MyThread extends Thread {
@Override
public void run() {
synchronized (o) {
try {
o.wait();
System.out.println(getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
MyThread t = new MyThread();
t.start();
}
// 保证了子线程们先 wait,主线程就先休眠一会儿
TimeUnit.SECONDS.sleep(5);
synchronized (o) {
// o.notify();
// 唤醒所有o的锁
o.notifyAll();
}
}
}
4 wait 和 sleep 的对比
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞 一段时间
相同点:sleep方法和wait方法都可以用来放弃CPU一定的时间(让线程放弃执行一段时间)
- wait 需要搭配 synchronized 使用. sleep 不需要.
- wait 是 Object 的方法 sleep 是 Thread 的静态方法
- 如果线程持有某个对象的监视器,sleep方法不会放弃这个对象的监视器,wait方法会放弃这个对象的监视器