复习 - 并发
一、基本概念
1. 进程和线程
进程:进程是程序的一次执行过程。是CPU资源分配的最小单位。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
线程:线程是CPU调度的最小单位,同一个进程下的多个线程共享此进程的全部资源。
- 一个进程中可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
- Java 中,线程作为小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
两者对比
- 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
- 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享堆和方法区(1.8 转到直接内存的元空间),每个线程都有自己独立的程序计数器、虚拟机栈和本地方法栈,线程之间切换的开销小。
- 包含关系:一般一个进程内有多个线程,执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
- 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响。但是一个线程崩溃可能导致整个进程都死掉。所以多进程要比多线程健壮。
- 通信方面:进程间通信较为复杂 同一台计算机的进程通信称为 IPC;不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP。
2. 并发与并行
- 并行是指两个或者多个事件在同一时刻发生
- 并发是指两个或多个事件在同一时间间隔发生
同步与异步
需要等待结果返回才能继续运行的话就是同步
不需要等待就是异步
结论
-
单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活
-
多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
-
有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(阿姆达尔定律)
-
也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
-
-
IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一 直等待 IO 结束,没能充分利用线程。
二、java线程
1、线程创建与运行
1. 使用 Thread
创建继承于Thread类的子类,并重写Thread类的run()方法
public static void main(String[] args) {
// 匿名内部类方式创建 Thread 创建进程的时候最好指定个名称
Thread t = new Thread("t1") {
@Override
public void run() {
// t1线程输出
log.debug("running");
}
};
t.start();
// 主线程输出
log.debug("running");
}
// lambda简化
@Test
public void test01() {
Thread t = new Thread(() -> log.debug("running"));
t.start();
}
2. 使用 Runnable 配合 Thread(推荐)
public static void main(String[] args) {
Runnable r = new Runnable() {
@Override
public void run() {
log.debug("running...");
}
};
// 因为Thread线程类实现了接口Runnable 所以可以直接用Thread的构造函数传递
Thread t = new Thread(r, "t2");
t.start();
}
使用 lambda简化 上述操作
public static void main(String[] args) {
// JDK会把只有1个抽象方法的接口加上 @FunctionalInterface 注解。有此注解可直接用lambda简化
Runnable r = () -> log.debug("running");
new Thread(r, "t1").start();
}
为啥使用 log 打印日志,而不是sout?因为sout加锁了(synchronized),加锁好像会同步执行
方法二,Runnable对象作为参数走的是Thread的原始run()方法;而方法一,实际线程调用的的是重写后的run()方法。
3. FutureTask配合Thread
FutureTask能够接收Callable类型的参数,用来处理有返回结果的情况。即FutureTask是在Runnable基础上加了返回值。
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> task = new FutureTask<>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
log.debug("running...");
Thread.sleep(2000);
return 100;
}
});
Thread t1 = new Thread(task, "t1");
t1.start();
// 注意,当要获取进程的返回结果时,会阻塞到结果的返回。即如果此进程需要执行2s,那么会卡在这里2s。
log.debug("{}", task.get());
}
lambda简化
public static void main(String[] args) throws Exception {
FutureTask<Integer> future = new FutureTask<Integer>(() -> {
log.debug("running...");
Thread.sleep(2000);
return 100;
});
// 2. 传入 future, 因为 FutureTask 这个类是实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口
new Thread(future, "t1").start();
// 3. 主线程阻塞,同步等待 future 执行完毕的结果。如果不调用获取返回值方法不会阻塞。
Integer result = future.get();
log.debug("{}", result);
}
2. 线程上下文切换
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 上下文切换 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- 上下文切换 频繁发生会影响性能
所以说,线程数不是开的越多越好,因为线程的上下文切换都需要时间,开的线程越多,整体的吞吐量越高
4、Thread 的常见方法
1. start() 与 run()
使用 start 方式,CPU 会为创建的线程分配时间片,线程进入运行状态(实际处于就绪状态,只不过java中就绪与运行都是运行状态),然后线程调用 run 方法执行逻辑。(run才是真正的运行状态 被cpu调度执行)
2. sleep()与yield()
sleep
- 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
- 睡眠结束后的线程未必会立刻得到执行
- 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
sleep()在哪个线程中被调用,就让哪个线程睡眠
yield
- 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
- 具体的实现依赖于操作系统的任务调度器
yield会让当前线程让出cpu的使用权,但是可能出现,刚让出使用权后,又获得了cpu的时间片
就绪状态有机会获得时间片,阻塞状态不能获得时间片。阻塞状态需要等待阻塞状态过后进入就绪状态
3. join() 方法
用于等待某个线程结束。哪个线程内调用join()方法,就等待哪个线程结束,然后再去执行其他线程。
如在主线程中调用ti.join(),则是主线程等待t1线程结束,join 采用同步。
Thread t1 = new Thread();
// 等待 t1 线程执行结束
t1.join();
// 最多等待 1000ms,如果 1000ms 内线程执行完毕,则会直接执行下面的语句,不会等够 1000ms。可能会提前结束
t1.join(1000);
// sleep等待的时间是固定的(即必须等多久)
4. interrupt 方法
打断 sleep,wait,join 的线程。这几个方法都会让线程进入阻塞状态
打断 sleep 的线程, 会清空打断状态,以 sleep 为例
private static void test1() throws InterruptedException {
Thread t1 = new Thread(()->{
sleep(1);
}, "t1");
t1.start();
sleep(0.5);
t1.interrupt();
log.debug(" 打断状态: {}", t1.isInterrupted()); // false
}
注意:打断 sleep,wait,join 的线程。当线程处于阻塞状态时被打断才会标志位false。线程在执行时被打断,标志位为true
7. 线程的状态(生命周期)
1. 线程的 5 种状态
1.新建(new):新创建了一个线程对象。
2.就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。
3.运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
4.阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。
- 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
- 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
- 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
5.死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。
三、共享模型之管程
1. 线程共享带来的问题
线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了。
例子
// 静态变量是上下文共享的
// 静态变量是上下文共享的
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 线程t1对共享变量count进行修改
Thread t1 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
count++;
}
});
// 线程t2对共享变量count进行修改
Thread t2 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
count--;
}
});
// 启动线程
t1.start();
t2.start();
// main等待t1结束,t1在跑时,t2也同时在跑
t1.join();
t2.join();
// 反正很难为0,值不确定
log.debug("count的值是{}",count);
}
因为 Java 中对静态变量的自增,自减并不是原子操作,如上代码,当执行 count++(count是静态变量) 或者 count-- 操作的时候,从字节码分析,实际上是 4 步操作。
count++; // 操作字节码如下:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
count--; // 操作字节码如下:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
由于不是原子性操作,多线程下这 8 行代码可能交错运行,多线程cpu分时时间片执行
- 出现负数的情况
如图,当t1分到了cpu时间片执行指令,对count--进行计算,但还没写入count,就没得时间片了。
于是t2抢到了时间片对count++完整计算后,线程1再次获得时间片,将-1的结果写入count,覆盖了count++的结果
出现正数的原理也差不多这意思
所以,所谓线程安全,是指多个线程对同一个共享资源进行的修改
2.临界区
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
个人理解:共享变量+读写操作+多线程 = 临界区
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
3. synchronized 重量级锁
为了避免临界区中的竞态条件发生,由多种手段可以达到。
- 阻塞式解决方案:synchronized ,Lock
- 非阻塞式解决方案:原子变量
synchronized 俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
1.synchronized 语法
synchronized(对象) { // 线程1(获得锁), 线程2(blocked阻塞)
//临界区
}
重量级锁解决上述案例
static final Object room = new Object();
@Test
public void test5() throws InterruptedException {
// 线程t1对共享变量count进行修改
Thread t1 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
// 对共享资源加锁
synchronized (room) {
count++;
}
}
});
// 线程t2对共享变量count进行修改
Thread t2 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
// 对共享资源加锁
synchronized (room) {
count--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// count = 0
log.info("count的值是 " + count);
}
如果临界区是WC,对象锁是钥匙,则只有有钥匙的人才能进去,而其他人只能在外边排队
对象锁被t1线程拿走,即使失去时间片,也不会归还对象锁,直到代码块中内容执行完。才会释放对象锁,而后其他线程竞争对象锁。
由此可以看出,对象锁的效率挺低的
对象锁相当于将临界区变成了原子性整体(不能被线程分割)。
如果把 synchronized(obj) 放在 for 循环的外面,如何理解?
// 如果将对象锁放在了for循环外,相当于将5000次循环作为了一个原子性整体,不会被其他线程干扰,即只有线程1对变量进行了5000次操作,其他线程才能对共享变量操作
Thread t1 = new Thread(() -> {
synchronized (room) {
for (int i = 1; i < 5000; i++) {
count++;
}
}
});
如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?
// 需要对同一个对象加锁,不然跟没加锁效果一样
Thread t1 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
synchronized (room) {
count++;
}
}
});
// 线程t2对共享变量count进行修改
Thread t2 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
synchronized (room1) {
count--;
}
}
});
2. 方法上的synchronized
加在成员方法上,锁住的是对象
class Test{
// 在方法上加上synchronized关键字
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) { // 锁住的是对象
}
}
}
加在静态方法上,锁住的是类对象
public class Test {
// 在静态方法上加上 synchronized 关键字
public synchronized static void test() {
}
//等价于
public void test() {
synchronized(Test.class) { // 锁住的是类
}
}
}
区别:锁对象锁的是当前对象,锁类对象锁的是调用这个方法的所有对象
锁对象锁
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
// 输出结果:2,1秒后 1
// a()锁的是 n1对象,b()锁的是 n2对象。于是达不到互斥效果。即跟没锁一样
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
锁类对象锁
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
// 输出结果:2,1秒后 1 或 1秒后 1,2
// a()锁的是 Number类对象,b()锁的是 Number类对象。达成互斥效果。
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
最容易错:
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
// 输出结果:2,1秒后 1
// a()锁的是 Number类对象,b()锁的是 n1对象。达不成互斥效果。即跟没锁一样
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
4. 变量的线程安全分析
1. 成员变量和静态变量的线程安全分析
变量在线程间共享期间,如果变量有读写操作,则这段代码就是临界区,需要考虑线程安全问题
2. 局部变量是否线程安全
- 局部变量【局部变量被初始化为基本数据类型】是安全的
- 局部变量是引用类型或者是对象引用则未必是安全的
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
所谓逃离方法作用范围,指的是return变量给其他方法用
3. 局部变量线程安全分析
-
局部变量为基本类型
每个线程都有属于自己的栈空间,基本数据类型变量会存放在各自的栈空间中。互不影响,所以并不存在线程安全问题 -
局部变量为引用类型
public final void method1() {
MyAspect myAspect = new MyAspect();
System.out.println(myAspect);
}
@Test
public void testMet() {
for (int i = 0; i < 3; i++) {
new Thread(this::method1, "Thread" + i).start();
}
}
myAspect对象是局部变量,每个线程调用时会创建其不同实例,没有共享。我这里打印了其地址,很明细不一致,地址不一致代表对象不是同一个
结论,引用类型的局部变量,只要不发生逃逸,是不会产生线程安全的
4. 常见线程安全类
String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。
// 创建一个实例
Hashtable table = new Hashtable();
new Thread(()->{
// 调用这个实例的同一个方法
table.put("key", "value1");
}).start();
new Thread(()->{
// 调用这个实例的同一个方法
table.put("key", "value2");
}).start();
它们的每个方法是原子的;但注意它们多个方法的组合不是原子的
Hashtable table = new Hashtable();
// 线程1,线程2
// 这里的get是原子的
if( table.get("key") == null) {
// 这里的put也是原子的
table.put("key", value);
}
// 但是它俩组合,就不安全了
线程1在get("key") == null时,就已经释放锁了,因为get是线程安全的。
如果此时失去了cpu时间片,t1执行完get释放了锁,此时t1还没执行put;而t2也可以执行get,也进入了if,然后put覆盖了table对象值。
5. 不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
String 中的 replace,substring 等方法"可以"改变值啊,那么这些方法又是如何保证线程安全的呢?
比如:String.substring() 源码
其实内部实现没有改变属性,而是创建了一个新的对象返回。因此不可变对象和无状态对象都是线程安全的
6. Monitor 概念
1. Java 对象头
无论多少位虚拟机的对象头都是8字节
一个对象的结构如下:
// int 4字节 Integer:8(对象头)+4(int)+对其填充=16字节(虚拟机要求对象起始地址必须是8字节的整数倍。)
2. Monitor 原理(重量级锁)
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
以故事形式:Monitor 是一个带锁和钥匙的食堂,下课后,一堆同学涌进来想吃饭。
小明先拿到了钥匙(执行了synchronized(obj)),成为了食堂的主人(Owner)。小明进房间吃饭,其他没抢到钥匙锁的就只能去等待室(EntryList)排队等候。
WaitSet:之前有钥匙锁进房间吃饭,然后中途休息(wait())的休息室(进WaitSet会释放锁)。只能被食堂主人(Owner)叫醒(notify()|notifyAll()),叫醒后还要去等待室排队。
当小明吃完饭,释放了钥匙锁,等待室的人就可以继续竞争了(竞争的时是非公平的)
-------------synchronized锁升级没搞明白-------------
3. Wait 与 Sleep 的区别
- Sleep 是 Thread 类的静态方法,Wait 是 Object 的方法,Object 又是所有类的父类,所以所有类都有Wait方法。
- Sleep 在阻塞的时候不会释放锁,而 Wait 在阻塞的时候会释放锁,它们都会释放 CPU 时间片资源。
- Sleep 不需要与 synchronized 一起使用,而 Wait 需要与 synchronized 一起使用(对象被锁以后才能使用)
- 使用 wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。
- wait()会进入waiting状态
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
sleep(long n) 和 wait(long n) 的相同点
1.均可响应中断 2.都会进入timed_waiting状态
;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?