Java并发-常见基础

本文最后更新于:2 年前

线程与进程与协程

进程是程序的一次执行过程,是系统资源分配的基本单位。进程间基本上是独立的。

线程是更小的运行单位。线程是调度执行的基本单位。线程间极有可能相互影响。

多个线程可以共享同一块内存空间与系统资源(堆,方法区(元空间)),也有自己独立的程序计数器与虚拟机栈,本地方法栈

线程的切换负担要小很多。也被称为轻量级的进程。

程序计数器:为了线程切换后可以恢复到正确的执行位置。
虚拟机栈:方法调用对应着栈帧,存储着局部变量表,操作数栈,常量池。方法调用执行完成对应着栈帧的入栈与出栈。–保证局部变量私有
堆:最大的一块,存放对象。
方法区:已被加载的类信息,常量,静态变量等等。
上下文切换:cpu 时间片切换。

一个 java 应用程序实际上至少有三个线程,main 主线程,gc()垃圾回收机制的运行线程,异常处理线程。

线程分为两类:用户线程和守护线程。守护线程适用于服务用户线程的。用户线程结束,守护线程也就结束,所以守护线程是依赖于用户线程的。举个例子:java 程序中,main 是用户线程,垃圾回收就是守护线程。可以利用 thread.setDaemon(true)将用户线程变成守护线程。

协程

操作系统在线程等待 IO 的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待 IO 的过程中,其它线程可以继续执行。当系统线程较少的时候没有什么问题,但是当线程数量非常多的时候,却产生了问题。一是系统线程会占用非常多的内存空间,二是过多的线程切换会占用大量的系统时间。

协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。

假设协程运行在线程之上,并且协程调用了一个阻塞 IO 操作,这时候会发生什么?

实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞 IO 操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的。

在有大量 IO 操作业务的情况下,我们采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升。

在协程中尽量不要调用阻塞 IO 的方法,比如打印,读取文件,Socket 接口等,除非改为异步调用的方式,并且协程只有在 IO 密集型的任务中才会发挥作用。

协程只有和异步 IO 结合起来才能发挥出最大的威力。

线程的相关方法:

sleep-

yield-让出 cpu,当然实际情况得看调度器

join-调用 join 的线程会让其他线程等待他结束后再结束。

sleep()与 wait()方法的区别

  1. sleep 方法不会释放锁,wait 方法会释放。
  2. 都可以暂停线程。
  3. wait 用于线程交互,sleep 用于暂停。
  4. wait 被调用后,线程不会自动苏醒(无超时等待),需要 notify()。

java 线程的状态

  • new:线程创建,还没调用 start 方法。
  • runnable:(操作系统中的就绪,运行)new–调用 start–ready--获得 cpu 时间片–running
  • blocked:阻塞。调用同步方法,在没获取到锁的情况下会阻塞。
  • waitting:等待其他线程做一些操作。wait
  • time_waiting:超时等待。-wait,sleep,超时时间到后,进入 runnable。
  • terminated:线程执行完毕。

wait/notify 属于 Object 类。

image-20210803151242958

创建线程的方式

继承 thread 类,重写 run()方法,调用子类的 start()。

为什么调用的是 start,执行的确是 run?

start 表示启动该线程,进入就绪状态,成为单独的执行流。操作系统会分配相关资源。

直接执行 run 就相当于执行 main 中的普通方法了,就不是多线程了。

实现 runnable 接口,重写 run,启动 start。

java 只支持单继承,因此引入 runnable 接口。

通过 Callable 和 Future 创建线程。覆盖 call()方法。

可以获得任务执行返回值;

  1. 创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法将作为线程执行体,并且有返回值。
  2. 创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值。
  3. 使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
  4. 调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CallableThreadTest implements Callable<Integer> {
public static void main(String[] args){
CallableThreadTest ctt = new CallableThreadTest();
FutureTask<Integer> ft = new FutureTask<>(ctt);
for(int i = 0;i < 100;i++){
System.out.println(Thread.currentThread().getName()+" 的循环变量i的值"+i);
if(i==20){
new Thread(ft,"有返回值的线程").start();
}
}try{
System.out.println("子线程的返回值:"+ft.get());
} catch (InterruptedException e){
e.printStackTrace();
} catch (ExecutionException e){
e.printStackTrace();
}
}
@Override
public Integer call() throws Exception{
int i = 0;
for(;i<100;i++){
System.out.println(Thread.currentThread().getName()+" "+i);
}
return i;
}
}

通过与 Future 的结合,可以实现利用 Future 来跟踪异步计算的结果。

JDK5.0 新特性。需要借助 Future 接口的唯一实现类FutureTask辅助线程的对象创建和返回值获取(FutureTask 还实现了 Runnable 接口),再创建 Thread 对象,将 FutureTask 类的对象作为构造器参数传入,完成线程的创建,最后调用 start()方法完成线程启动。

Runnable 和 Callable 的区别:

1、Callable 规定的方法是 call(), Runnable 规定的方法是 run()
2、Callable 的任务执行后可返回值,而 Runnable 的任务是不能返回值
3、call 方法可以抛出异常,run 方法不可以
4、运行 Callable 任务可以拿到一个 Future 对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过 Future 对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果

使用线程池。

使用线程池,提前创建好多个线程放入线程池中,使用时直接获取,使用完放回线程池中。可以做到提高响应速度(减少线程创建的时间)和降低资源消耗(可重复利用线程)。利用 Executors 工具类创建线程池,然后提供 Runnable(excute())或 Callable(submit())接口的实现类的对象,执行指定线程的操作。最后,关闭线程池 shutdown()。

内存屏障

为什么会有内存屏障

每个 CPU 都会有自己的缓存(有的甚至 L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同 CPU 执行的不同线程对同一个变量的缓存值不同。

用 volatile 关键字修饰变量可以解决上述问题,那么 volatile 是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java 通过屏蔽这些差异,统一由 jvm 来生成内存屏障的指令。

内存屏障是什么

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier 即读屏障和写屏障。

内存屏障有两个作用:

阻止屏障两侧的指令重排序;
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

java 内存屏障

java 的内存屏障通常所谓的四种即 LoadLoad,StoreStore,LoadStore,StoreLoad 实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

volatile 语义中的内存屏障

volatile 的内存屏障策略非常严格保守,非常悲观
在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障;
在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入 LoadStore 屏障;

由于内存屏障的作用,避免了 volatile 变量和其它指令重排序、线程之间实现了通信,使得 volatile 表现出了锁的特性

final 语义中的内存屏障

对于 final 域,编译器和 CPU 会遵循两个排序规则:

新建对象过程中,构造体中对 final 域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;

初次读包含 final 域的对象引用和读取这个 final 域,这两个操作不能重排序;(晦涩,意思就是先赋值引用,再调用 final 值)

​ 必需保证一个对象的所有 final 域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:

​ 写 final 域:在编译器写 final 域完毕,构造体结束之前,会插入一个 StoreStore 屏障,保证前面的对 final 写入对其他线程/CPU 可见,并阻止重排序。

​ 读 final 域:读 final 域前插入了 LoadLoad 屏障。