进程
简而言之,一个运行的程序就叫作进程
管理进程,要先将进程使用类/结构体,表示各个属性
为了后续增删改查,需要将进程通过数据结构串联起来
系统中有一个结构体专门用来表示进程的属性,叫作PCB(进程控制块)
一个进程使用一个或者多个PCB来表示
系统会使用类似于双向链表的数据结构来管理多个PCB
要想了解进程就要了解PCB
进程标识符(pid): PCB存放进程的编号,唯一性,一个进程一个编号
PCB拥有一组内存的属性,标记进程在内存的位置,存储内存有哪几部分,各部分的作用(进程持有的内存资源)
文件描述符表是PCB管理文件的属性,与硬盘有关,进程可以管理哪些文件,关联哪些文件,都要通过文件描述符表(进程持有的硬盘资源)
cpu单核处理多个进程,一个一个进行处理似乎很慢,但只要轮转的速度都快,超过肉眼的捕捉,感知上似乎各个进程独立进行,其实进程是一个接一个的进行,这就是并发
如果两个cpu核心分别处理各个的进程,这就是并行
进程的状态
进程时刻待命,随叫随到,叫作就绪状态
进程因为不满足某些条件而无法就绪,叫作阻塞状态
进程还有僵尸状态等等,此处不一一赘述
进程的优先级
操作系统决定进程执行顺序的属性
进程的上下文
cpu进程需要并发处理,进程一个接一个地进行,这就需要进程记住上一次各种存储器的状态,来进行不断轮转,不妨碍任务进程执行
CPU中有些寄存器没有特殊含义,存储中间产生的结果,有些存储器有特殊含义
程序计数器存储当前执行指令的内存地址,读取完后会自动读取下一条地址,一般是顺序读取,遇到跳转条件会更新对应跳转语句的内存地址
exe包括指令和数据,执行过程中会先把指令和数据加载到内存中,等待读取
CPU会从内存取命令,读取命令
维护栈的寄存器
调用栈需要一组寄存器(一般是两个寄存器)来维护,存储程序方法调用过程中的一系列关系(临时变量,方法参数...)
ebp始终指向栈底,esp始终指向栈顶
其他寄存器
其他寄存器存储计算工程的中间值
比如你计算1+2+3,算完1+2后,突然进程调度跳转,寄存器会存储1+2的结果3,存储结果的寄存器通常会一并放到内存,占比不大,等到进程恢复调度继续进行
记账信息
通过进程优先级,不同的进程会分得不同权重的CPU,但是可能会出现极端情况,使得一些存在感低的进程可能无法占用CPU运行而终止进程,所以我们使用记账信息来时刻观察这种情况
虚拟内存地址
进程和物理内存的映射不经过修饰的话,可能会因为野指针等情况,访问到别的进程,操作别的进程数据而造成影响,甚至瘫痪,所以我们要在进程和物理内存中间加上校验封装.
如果存在A进程要对虚拟地址进行操作,A要先把虚拟地址交给系统进行校验,如果翻译的物理地址(虚拟地址翻译成物理地址,有一个类似于hash表这样的映射结构)不越界,则会对物理地址相应的进行操作,如果地址违法,会进行处理,防止波及其他进程
每个进程分配各自的虚拟空间,在各自的进程保持独立,这就是进程的独立性
进程具有独立性,但是有些进程需要相互配合,这时我们需要一个公共空间实现数据的交互
而Java中多进程通信方式主要是通过文件和网络(socket)
Java更推荐使用多线程,多进程的出现是为了迎合现在多核时代,利用多核使得进程并发进行,但是进程其实是个重量活,创建或者调度或者销毁进程耗时都比较大,比如网站访问的用户量大的时候,频繁访问服务器,使得进程不断的进行刚才的过程,开销就不可忽视,从而引出线程
线程又叫作轻量级进程,线程不能独立存在,而是需要依附于进程,进程可以存在一个或者多个线程,进程的轻量体验在创建,调度,销毁等比进程都更快
一个进程至少有一个线程,这个线程实现完成代码,而一个进程含有多个线程时,可以进行多个线程来分别独立指向代码
前面提到进程调度,都是基于一个进程只有一个线程的情况,而一个进程含有多个线程,每个线程都有优先级,上下文,记账信息,状态...但是这些多个线程共用这个进程的pid,内存指针,文件描述符表
综上所述,我们可以得到线程的特点:
1. 每一个线程都可以独立的去CPU调度执行
2. 一个进程的多个进程共用同一块内存空间,文件资源等
创建线程不需要重新申请资源,创建和销毁的效率更高
进程时分配资源的基本单位,线程是进行调度的基本单位
一个系统可以有多个进程,每个进程都有自己的资源
一个进程可以有多个线程,每个线程共用内存/硬盘资源
但是一个进程含有的线程是有限的,如果你超额加入线程,资源不够就使得某些线程会受到影响,产生冲突,这就是线程不安全
如果某个线程出现异常,没有处理,则会使得进程崩溃,其他线程随之消亡
线程和进程的区别:
1. 进程包括一个或多个线程
2. 进程是资源分配的基本单位,线程是调度执行的基本单位
3.进程相互独立,不会相互影响,一个线程出现问题,可能会影响其他同一进程的线程(线程安全问题/线程异常)
4. 进程和线程都是用于并发场景,进程更加轻量级
5. 同一进程的线程共用同一份资源(内存和硬盘)
线程
class myThread extends Thread {
@Override
public void run() {
System.out.println("Thread");
}
}
public class Demo01 {
public static void main(String[] args) {
Thread myThread = new myThread();
myThread.start();
}
}
myThread实现的是run方法,为什么要调用start方法?
因为单纯调用run方法只会执行run方法的代码块而非创建一个新的线程,在当前主线程调用run方法仍然是在主线程执行run方法,而只有调用start方法才能创建一个新的线程,独立于当前线程,并执行调用run方法
class myThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("Thread");
}
}
}
public class Demo01 {
public static void main(String[] args) {
Thread myThread = new myThread();
myThread.start();
while (true) {
System.out.println("main");
}
}
}
屏幕会出现Thread和main交织,这是因为又开了一个线程,线程各自独立执行,单纯运行run方法会在原来的线程执行,因为我们是设计的死循环,会一直打印''Thread'',所以调用run方法不会像再创建线程一样使得线程独立运行
我们可以在线程运行时慢一点
class myThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
Thread myThread = new myThread();
myThread.start();
while (true) {
System.out.println("main");
Thread.sleep(1000);
}
}
}
随机打印,但是此处并非数学的等概率随机,这取决于操作系统的线程调度模块实现
public class Demo02 {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new myRunnable();
Thread thread = new Thread(runnable);
thread.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1000);
}
}
}
class myRunnable implements Runnable {
@Override
public void run() {
while (true) {
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
成功创建线程,我们通过实现Runnable接口来创建线程,Runnable表示的是一个可执行的任务,至于这个任务是给线程还是其他的实体来执行并不关心,刚才的两种创建线程的方式主要在于解耦
创建线程有两个关键操作:
1.明确要执行的任务
2.从系统调用API,创建线程
执行的任务不一定要与线程强相关,任务只是一段代码,使用单线程,多线程,或者线程池等执行都无所谓
任务提取出来,后面可以改成使用其他方式执行
使用匿名内部类类比于刚才的两种方法,创建线程:
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread() {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
};
thread.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1_000);
}
}
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
t.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1000);
}
}
通过Lambda表达式创建线程
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1000);
}
}
Thread构造方法可以给线程命名
给上述代码线程命名如下,增加一个String的参数用来给线程命名
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"新线程");
t.start();
}
我们命名"新线程"的线程就出现了,但是main方法消失了,因为主线程走完了,而新的线程还在运行(死循环)
前台线程:java进程中,前台线程没结束,java主进程就不结束
后台线程:后台进程不结束,不影响主进程结束
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"新线程");
t.setDaemon(true);
t.start();
}
设置为后台进程
主进程执行完了,后台进程还没开始,但是后台进程不影响主进程结束
中止进程
public class Demo07 {
private static boolean isQuit = false;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (!isQuit) {
System.out.println("线程进行ing");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
Thread.sleep(5000);
isQuit = true;
System.out.println("线程结束");
}
}
手动创建标志位来把线程中止
我们如果将isQuit变量放到main方法内部,会出现报错,但是你把下面的isQuit = true;注释掉,就只会报警告
Lambda表达式进行变量捕获,是对使用到的变量进行复制一份,那么复制的变量和原来的变量没有联系,只是复制值而已,所以Lambda表达式要求捕获的变量要么是final修饰或者类似于final修饰的结果(没有被修改)
如果使用变量判断来终止线程有时做不到立刻响应,因为sleep执行完毕才能对变量进行判断,比如你要在第五秒进行终止线程,但是线程sleep沉睡6秒,这是只有沉睡完6秒才能判断相应的条件,但是你其实第五秒就应该中止了,所以这种方法并不能做到及时响应
public static void main(String[] args) {
Thread t = new Thread(()->{
while (!Thread.currentThread().isInterrupted()) {
System.out.println("打印中");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("打印结束");
t.interrupt();
}
我们使用currentThread方法来对Thread线程进行捕捉,并判断是否中止,即便处于沉睡状态,还是会被唤醒,会使得sleep报出异常,但是上文并没有中止程序
Java给了一种自由的方式,当你发现异常,你可以选择不中止,继续运行,也可以再做一些操作,然后break中止,这样把选择的权力交给了程序员
线程等待,一个线程在等另一个线程结束才会执行,Java利用join来实现线程等待,使得线程进行可以有序
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.println("执行t线程");
}
});
t.start();
System.out.println("开始");
t.join();
System.out.println("结束");
}
如果t没有执行完,调用join的线程就会阻塞
如果执行完,则join的线程会直接退出,不涉及阻塞
线程状态
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
});
System.out.println(t.getState());
t.start();
t.join();
System.out.println(t.getState());
}
进程的核心状态: 就绪状态和阻塞状态
线程在此基础上还有其他状态, 线程的就绪状态可能正在运行或者在排队等待, NEW状态是描述线程未开始, TERMINATED状态描述线程结束状态
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true) {
}
});
System.out.println(t.getState());
t.start();
for (int i = 0; i < 3; i++) {
System.out.println(t.getState());
Thread.sleep(500);
}
t.join();
System.out.println(t.getState());
}
RUNNABLE表示线程处于运行状态或者等待cpu分配资源; TIMED_WAITING表示线程正在等待某种条件成立, 但是等待时间有限制
线程安全
当单线程运行顺利, 多线程运行出现bug, 就出现了线程安全问题
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 100000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 100000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
运行多次答案不为200000, 且都不一样, 出现bug, 如果将t1和t2分别运行各自线程则不会出现刚才的错误, 单线程逻辑并没有错
在上述程序运行的逻辑中, count++的步骤可以拆分为从内存获取到cpu, 进行+1操作, 将内容保存到内存, 上述步骤在多线程中因为线程调度的随机性会衍生无数种可能, 从而需要在编写过程将各自可能考虑其中
当两个线程的执行count++步骤的过程中, 如果未能在另一个线程执行完成保存结果到内存就已经开始执行自己的操作就会使得二者最后的执行结果只能使得count++一次, 而实际上操作进行两次, 所以出现刚才的bug
刚才出现线程安全的原因:
1. 线程调度随机, 使得两个进程可以穿插进行, 产生多种可能
2. count++操作非原子化. 如果刚才count++的三条指令一次性在cpu中进行(原子化), 这样使得结果也不会发生重叠
3. 两个线程对同一个变量进行修改
为了解决上述问题, 可以采用加锁的方式来进行约束, 使得两个线程在争夺同一个锁的时候, 会发生停滞等待, 避免发生冲突
synchronized 关键字
synchronized (锁对象) {
代码块
}
设立锁为了避免多线程冲突引发的矛盾, 但是如果两个锁不同就没有必要发生阻塞, 添加锁对象来判别锁是否相同, 如果两个线程的锁相同, 当一方加锁时, 另一方只能等待解锁才能执行, 处于阻塞状态
可重入
如果一个线程对一个锁进行加锁, 如果之后出现对刚才锁的使用时, 因为第一次锁必须等本线程同步代码块或者方法结束才会开锁, 但是要想结束第一次线程同步代码块或者方法就必须在后面第二次开锁后才行. 同一个线程对于一个锁的多次介入不会发生卡死, 这种锁具有可重入性
可重入锁会在加锁时判别该锁是否是该线程的锁, 即便之前已经加锁, 仍可以在同一线程进行获取, 即加锁成功
一个线程对可重入锁进行多次加锁, 会在引用计数为0时释放, 但并不是锁释放的唯一条件, 锁也会因为线程结束, JVM虚拟机结束, 又或线程异常而释放, 加锁会使引用计数+1, 解锁则-1.
死锁的情况:
1. 一个线程中同一把锁(非可重入锁)被连续加锁, 会出现死锁
2. 两个及其以上的线程因为锁的争夺而线程停滞造成死锁, 例如A线程持有锁a, 等待获取锁b, 同时线程B持有锁b, 等待获取锁a, 双方彼此相互等待获取对方的锁, 导致各自线程无法顺利进行造成死锁
死锁出现的必要条件:
1. 互斥使用, 资源被线程获取的时候, 其他线程不能获取
2. 不可抢占, 资源被线程获取不可被其他线程强行获取
3. 请求保持, 线程获取资源时, 会保持之前的资源
4. 循环等待/环路等待, 等待依赖关系形成环形
上述第一条和第二条是锁的基本特性无法更改, 只能对第三条和第四条进行解决, 可以通过避免锁嵌套来解决, 但有些锁嵌套无法避免, 解决循环等待可以根据优先级来分配资源, 但是容易使得资源分配不平衡, 可以约定锁顺序来避免循环等待
volatile 关键字
计算机运行访问数据频繁, 优先访问寄存器数据, 但是寄存器有限, 访问数据有限, 计算机采用多层缓存机制来减少内存慢速读取, 越靠近cpu速度越快但是容量较小, 在缓存无法得到访问的数据再去内存进行访问
private static int cmp = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (cmp==0) {
}
System.out.println("t1结束");
});
t1.start();
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入修改的值");
cmp = scanner.nextInt();
});
t2.start();
}
在t1线程cmp被频繁访问, 编译器和处理器可能会将这个变量缓存到CPU的寄存器中, 但是其他线程修改cmp内存上的值不会对t1线程寄存器上cmp的值产生影响, 出现内存可见性问题导致多线程安全, 需要实现内存同步就要使用volatile关键字
wait 和 notify
wait方法会使具有锁的线程释放锁, 阻塞等待被唤醒, 唤醒后需要重新进行锁的争夺
wait和notify的使用也可以避免"线程饿死", 一个线程为了等待某个条件反复进行加锁/释放锁的行为, 而多个线程在等待锁的释放, 导致这些线程无法正常运行产生"线程饿死", 因为线程的加锁需要进行调度, 而刚释放完锁的线程如果仍在等待的队列, 往往会容易进行加锁, 在线程释放之后使用wait, 在满足等待条件后唤醒
notify会唤醒一个等待状态的线程, notifyAll会唤醒同一个锁或条件变量的所有线程, 唤醒后并不意味着处于就绪状态, 线程唤醒后要进入cpu工作, 需要线程调度器的分配
多线程案例
单例模式
单例模式是一种设计模式, 单例指的是单个实例, 即某个类只能创建一个对象
饿汉模式
class Singleton {
private static Singleton singleton = new Singleton();
public static Singleton getSingleton() {
return singleton;
}
private Singleton() {}
}
类一加载就会创建对象
懒汉模式
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy() {
}
}
在调用对象时才会创建对象
刚才的代码, 饿汉模式是多线程安全的, 只会被读取不会被修改, 而懒汉模式读取且会被修改, 则会出现多线程安全问题, 可能会被创建多个对象而与单例模式冲突, 浪费更多的内存
解决懒汉的线程安全问题可以对其加锁, 但是并不是每次都需要加锁, 没有实例化对象需要进行加锁, 但是实例化对象之后加锁会使得运行效率降低
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {
}
}
上述代码可能会因为指令重排序而出错, 当线程A先执行并在初始化的过程中, 可能因为指令重排序优化的原因, 导致在初始化执行之前会把空间的地址赋值给了instance, 如果线程B进入, 此时检测到instance非空并返回, 但是instance地址非法, 为了这种情况发生, 需要使用volatile来防止指令重排序的优化
class SingletonLazy {
private volatile static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {
}
}
阻塞队列
阻塞队列是一种特殊的队列,它仍然遵循先进先出的原则。在生产者消费者模型中,阻塞队列起到了至关重要的作用。
当队列为空时,如果有线程尝试从队列中获取元素,该线程将会被阻塞,直到队列中有元素可供获取。
当队列已满时,如果有线程尝试向队列中添加元素,该线程将会被阻塞,直到队列中有空间可以容纳新元素。
生产者消费者模型
1. 解耦合
当服务器之间的耦合性较高时,一个服务器的问题可能会对与其紧密耦合的其他服务器产生重大影响。通过使用阻塞队列作为服务器之间的通信中介,可以降低这种耦合性。在分布式系统中,这种解耦合尤为重要,因为它使得系统的各个部分更加独立和可维护
2. 削峰填谷
不同的服务器具有不同的负载能力。当访问量突然增加时,虽然某些服务器(如服务器A)能够承载这种高负载,但与其交互的其他服务器(如服务器B)可能由于负载能力不足而面临崩溃的风险。此时,阻塞队列可以作为一个缓冲区,存储服务器A对服务器B的交互请求,从而平滑访问量的波动,降低服务器的压力
阻塞队列简单实现
class MyClockingQueue {
private static String[] queue = new String[1000];
private int front = 0;
private int tail = 0;
private int size = 0;
public void put(String s) throws InterruptedException {
synchronized(this) {
if(size == queue.length) {
this.wait();
}
queue[tail++] = s;
if(tail == queue.length) {
tail = 0;
}
size++;
this.notify();
}
}
public String take() throws InterruptedException {
synchronized(this) {
if(size == 0) {
this.wait();
}
String s = queue[front];
front++;
if(front==queue.length) {
front = 0;
}
size--;
this.notify();
return s;
}
}
}
上述代码中, 如果被interrupt打断, 而导致wait强制唤醒, 虽然上述代码有异常警告, 但是如果后续改成try-catch代码后仍存在风险, 所以每次在wait唤醒的时候都要进行判断条件, 而且上述变量因为存在读取和修改操作, 可能会引起指令重排序, 所以需要用volatile来进行修饰
class MyClockingQueue {
private static String[] queue = new String[1000];
private volatile int front = 0;
private volatile int tail = 0;
private volatile int size = 0;
public void put(String s) throws InterruptedException {
synchronized (this) {
while (size == queue.length) {
this.wait();
}
queue[tail++] = s;
if (tail == queue.length) {
tail = 0;
}
size++;
this.notify();
}
}
public String take() throws InterruptedException {
synchronized (this) {
while (size == 0) {
this.wait();
}
String s = queue[front];
front++;
if (front == queue.length) {
front = 0;
}
size--;
this.notify();
return s;
}
}
}
定时器
案例
import java.util.Timer;
import java.util.TimerTask;
public class Main {
public static void main(String[] args) {
//创建一个Timer的对象
Timer timer = new Timer();
//对象安排任务
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println(2000);
}
}, 2000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println(3000);
}
}, 3000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println(1000);
}
}, 1000);
System.out.println("程序启动");
}
}
实现定时器
class MyTimerTask implements Comparable<MyTimerTask> {
private Runnable runnable;
private long curTime;
MyTimerTask(Runnable runnable, long delay) {
this.runnable = runnable;
curTime = System.currentTimeMillis() + delay;
}
public Runnable getRunnable() {
return runnable;
}
public long getCurTime() {
return curTime;
}
@Override
public int compareTo(MyTimerTask o2) {
//小根堆, 最小的在前面
return (int) (this.curTime - o2.curTime);
}
}
class MyTimer {
//使用优先级队列, 优先处理时间最小的任务
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
Object locker = new Object();
public void schedule(Runnable runnable, long delay) {
synchronized (locker) {
queue.offer(new MyTimerTask(runnable, delay));
locker.notify();
}
}
MyTimer() {
Thread t = new Thread(() -> {
//反复检查是否到达事件
while (true) {
//检查队列是否非空
//wait尽量搭配while使用, 每次唤醒都要再次检查是否条件满足
synchronized (locker) {
while (queue.isEmpty()) {
//队列为空, 等待队列有元素进入
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//当前时间
long curTime = System.currentTimeMillis();
MyTimerTask task = queue.peek();
if (task.getCurTime() > curTime) {
//时间还没到, 就进行等待, 不然也是忙等, 一直在不停的判断
//这里不能使用sleep, 因为使用sleep后, 当出现一个比时间最小任务时间更小的时候
//因为此时处于沉睡状态, 所以不能被加入到队列也不能被执行
try {
locker.wait(task.getCurTime() - curTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
//时间到了
task.getRunnable().run();
queue.poll();
}
}
}
});
t.start();
}
}
线程池
线程相较于进程,消耗更小的资源并且具有更高的效率。然而,如果对线程进行频繁的创建和销毁,线程的开销将变得不可忽视。为了解决这个问题,我们可以从两方面进行考虑。
第一种是协程,它是一种轻量级的线程,通过协作式调度实现并发执行。然而,需要注意的是,Java标准库并未直接支持协程,因此在Java中直接使用协程可能并不合适。
第二种方法是使用线程池。线程池的概念可以通过以下场景来理解:冬天来临,动物每次饿了都需要出去捕猎,然后返回洞穴。频繁的出入不仅使得能量消耗巨大,还可能面临食物短缺的风险。因此,动物会提前准备好食物放在洞穴里,饿了就吃,这样既方便又节省能量。线程池的工作原理与此类似。它会预先创建并维护一组线程,当需要执行新任务时,直接从线程池中获取可用的线程来执行任务,从而避免了频繁创建和销毁线程的开销。通过这种方式,线程池能够显著提高系统的并发性能和响应速度
使用线程池获取线程主要是用户态操作,这些操作在应用程序的控制下进行,线程调度和处理也主要在应用程序层面进行。然而,需要注意的是,线程的执行和调度最终还是需要操作系统的支持,但线程池机制减少了与操作系统交互的频率和复杂性。
相比之下,创建线程需要与操作系统进行复杂的交互,通知操作系统分配所需的资源。同样,销毁线程也需要告知操作系统释放相关资源。这些操作涉及用户态和内核态的切换,具有一定的开销。由于操作系统需要同时处理多个进程和任务,线程的创建和销毁可能会因为其他任务而被延迟,从而降低线程的即时调度和工作效率。
为了解决这个问题,我们可以使用线程池。线程池在应用程序启动时预先创建并维护一组线程,当需要执行新任务时,直接从线程池中获取可用的线程。这样,就避免了频繁地与操作系统进行交互来创建和销毁线程,提高了系统的并发性能和响应速度。线程池的使用使得线程的创建和销毁开销得到了有效控制,提高了对线程的调度和管控效率
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo01 {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(4);
service.submit(new Runnable(){
@Override
public void run() {
System.out.println("你好");
}
});
}
}
在创建线程池对象时,直接使用构造方法可能会受到一些限制。构造方法的命名通常与类名保持一致,这是编程约定。在同一个类中,我们可以定义多个构造方法,只要它们的参数列表(即参数数量、顺序和类型)不同,这就是构造方法的重载。然而,在某些情况下,即使参数列表相同,我们也可能需要表达不同的创建需求。这时,直接使用构造方法可能无法满足需求。
为了解决这个问题,我们可以引入工厂模式。工厂模式是一种创建型设计模式,它使用单独的类来负责创建其他类的实例。通过定义不同的工厂方法,我们可以区分不同的创建需求,即使它们的参数列表相同。这样,我们可以更加灵活地控制对象的创建过程,提高代码的可维护性和可读性。
使用工厂模式时,我们需要考虑代码的简洁性和可读性。确保工厂方法的命名清晰明了,能够直观地反映其创建对象的类型和特点。同时,也要注意避免过度使用工厂模式,以免增加代码的复杂性和维护成本。
java标准库中, 线程池ThreadPoolExecutor的构造方法存在很多参数需要理解
corePoolSize是核心线程的数量,核心线程在处于空闲状态时仍保持在线,直到接收新的任务。maximumPoolSize代表线程池能够容纳的最大线程数,包括核心线程数量和非核心线程数量。keepAliveTime和unit二者结合定义了非核心线程在空闲状态且此时线程池中的线程总数大于corePoolSize时的最长存活时间,一旦超出这个时间范围,这些非核心线程就会被系统销毁。workQueue是在线程都处于忙碌状态时,新提交的任务会存放到的阻塞队列。根据对阻塞队列的不同要求,可以选择使用不同的阻塞队列,例如优先级阻塞队列、数组阻塞队列、链表阻塞队列等。threadFactory是创建线程的工厂,用于控制线程的创建过程,包括线程的优先级、名称、是否是守护线程等。handler是在线程数达到了最大值且都处于忙碌状态且阻塞队列已满时,用于处理无法执行的新任务的拒绝策略
关于Java线程池自带的拒绝策略,第一种是AbortPolicy
,它会直接抛出RejectedExecutionException
异常,从而阻止系统正常运行,这是默认的拒绝策略;第二种是CallerRunsPolicy
,它会让调用execute
方法的线程来处理新添加的任务,而不是在线程池中创建新线程;第三种是DiscardOldestPolicy
,它会丢弃工作队列中最旧的一个任务,然后尝试重新执行新任务;第四种是DiscardPolicy
,它会直接丢弃新的任务,不做任何处理。
多线程进阶
常见的锁策略
乐观锁和悲观锁
对于预期锁冲突较小使用乐观锁
相反的, 预期锁冲突较大就使用悲观锁
重量级锁和轻量级锁
重量级锁是锁冲突而导致的资源占用较多的锁
轻量级锁则相反, 是锁冲突而导致的资源占用较小的锁
读写锁
读锁和读锁不会冲突, 读锁和写锁会发生冲突, 写锁和写锁会发生冲突
因为在多线程中读取数据不会发生多线程安全的问题, 所以读锁之间不会发生冲突
可重入锁和不可重入锁
对于同一把锁可以在一个线程中多次进行加锁, 使用计数器来对锁的施加进行计数, 并在释放锁后进行自减操作
不可重入锁就无法实现多次加锁, 强行多次加同一把锁会使得相互等待彼此释放而无法终止正常终止线程
公平锁和非公平锁
公平锁遵循着先到先得的原则, 当一个线程释放锁后, 锁会优先给予先等待的线程获取
非公平锁则是无论前后顺序, 当锁释放后就遵循要获取锁的线程等概率获取该锁
自旋锁和挂起等待锁
挂起等待锁是在锁冲突发生时, 调用系统API, 进行阻塞等待
自旋锁就是使用循环来不停判断锁的状态来进行忙等, 但是响应速度快, 而忙等又会浪费cpu资源
CAS
CAS(Compare and Swap)是一个原子操作,它包含三个操作数:内存地址V、预期原值A和新值B。CAS操作的逻辑是:当内存地址V的值等于预期原值A时,将该内存地址的值更新为新值B。否则,不做任何操作。这种机制使得CAS能够在多线程环境中安全地修改共享变量。
CAS的应用非常广泛,尤其在实现无锁数据结构时。例如,AtomicInteger
类就利用CAS操作实现了线程安全的计数器。
然而,CAS并非完美无缺。其中一个著名的问题就是ABA问题。ABA问题指的是,当一个变量的值从A变成B,然后又变回A时,尽管最终的值与初始值相同,但在这个过程中变量实际上已经被其他线程修改过。这种情况可能导致某些基于CAS实现的算法出现错误。
为了解决ABA问题,一种常见的做法是引入版本号机制。每次变量更新时,除了修改实际的值,还会递增一个版本号。CAS操作在比较时,除了比较实际的值,还会比较版本号。这样,即使变量的值最终变回了A,但由于版本号已经改变,CAS操作仍然能够识别出变量已经被修改过,从而避免ABA问题。
需要注意的是,CAS虽然轻量级且在某些场景下非常有用,但在高并发情况下可能会引发大量自旋重试,导致CPU资源的浪费。因此,在使用CAS时,需要权衡其优点和潜在的性能问题。
此外,CAS并不能完全替代锁。在某些复杂的并发场景下,可能还需要结合其他同步机制来实现正确的并发控制。因此,在选择使用CAS还是锁时,需要根据具体的业务场景和需求来做出决策。
Synchronized
锁升级
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
举个栗子:线程A是一个人,要去图书室看书。图书室同一时间只能让一个人在里面安静地看书。最开始,线程A看见图书室的钥匙挂在外面,便用钥匙打开锁进去,并在外面挂上自己的号码牌(此时处于偏向锁状态)。线程B这时候也想进图书室看书,但看到外面挂着线程A的号码牌,只能在外面等。此时若没有其他线程与线程B竞争,线程B只需等待线程A出来还钥匙,然后线程B再挂上自己的号码牌即可进入。但是,如果此时线程C也想要进入图书室,那么就发生了锁竞争。为了应对这种竞争,偏向锁会升级成轻量级锁。线程C会尝试通过自旋(即不断尝试获取锁)的方式等待线程A或线程B释放锁。然而,随着想要进入图书室的线程越来越多,轻量级锁的自旋方式会消耗大量的CPU资源,此时轻量级锁会进一步升级为重量级锁。在重量级锁的状态下,等待的线程会被阻塞,直到获得锁为止。
锁消除
锁消除是一种针对锁的优化手段。当JVM在运行时发现某段代码中的锁实际上并不需要时(例如,多个线程访问某段代码时并不会导致数据不一致),JVM会消除这些不必要的锁,从而减少不必要的开销,提高程序的执行效率。
锁粗化
锁粗化是另一种优化手段。当多个相邻的同步块逻辑上可以被整合成一个较大的同步块时,为了提高程序的执行效率,可以将这些同步块进行粗化。这样可以减少锁的获取和释放次数,从而减少线程间的竞争。但需要注意的是,锁粗化可能会导致锁的持有时间延长,增加其他线程的等待时间。因此,在进行锁粗化时,需要综合考虑其对程序性能的影响。
Callable
Callable
接口允许我们创建有返回值的多线程任务。然而,Thread
类的构造函数只接受实现了Runnable
接口的对象,这意味着我们不能直接将Callable
对象放入Thread
中执行。为了解决这个问题,我们可以使用FutureTask
。FutureTask
是Future
和Runnable
接口的实现,它接受一个Callable
对象作为参数,并封装该对象的call()
方法的执行。因此,我们可以将Callable
对象放入FutureTask
中,然后将FutureTask
对象作为参数传递给Thread
。
task.get()
方法会阻塞当前线程,直到Callable
对象的call()
方法执行完毕并返回结果。
除了使用Callable
和FutureTask
创建有返回值的线程任务外,还有其他几种创建线程的方法:
-
继承
Thread
类并重写run()
方法:这是创建线程的一种基本方式,但需要注意避免Java的单继承限制。 -
实现
Runnable
接口并重写run()
方法:这种方式更加灵活,因为Java类可以实现多个接口。 -
使用线程池:线程池可以管理一组线程,避免频繁创建和销毁线程带来的开销。
-
使用
ThreadFactory
线程工厂:ThreadFactory
是一个用于创建新线程的工厂接口,它可以提供自定义的线程创建逻辑。 -
使用Lambda表达式:使用Lambda表达式来简洁地创建线程,通常与线程池结合使用。
ReentrantLock
ReentrantLock是一把可重入锁,它允许同一线程多次获取同一把锁。释放锁需要使用unlock()
方法。ReentrantLock可以实现公平锁和非公平锁,其中默认为非公平锁。通过构造函数的参数设置,可以选择是否使用公平锁。此外,ReentrantLock可以与Condition结合使用,以实现更细粒度的线程通知和等待机制,从而能够更灵活地控制线程间的协作。
信号量 Semaphore
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4); // 创建一个信号量,表示有4个资源可用
Runnable runnable = new Runnable(){
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "尝试获取资源");
semaphore.acquire(); // 尝试获取一个资源许可
// 在这里执行对共享资源的访问和操作
System.out.println(Thread.currentThread().getName() + "获取到资源并执行操作");
// 模拟操作完成后释放资源
System.out.println(Thread.currentThread().getName() + "释放资源");
semaphore.release(); // 释放一个资源许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start(); // 启动线程,尝试并发地获取和使用资源
}
}
信号量表示资源的个数, 进行P操作, 使用acquire()获取资源, 资源耗尽时获取会进行阻塞等待; 进行V操作, release()进行释放资源
CountDownLatch
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("运行中");
latch.countDown();
}
};
for (int i = 0; i < 10; i++) {
new Thread(runnable).start();
}
latch.await();
System.out.println("运行结束");
}
在上面的代码中,我们使用了CountDownLatch
的await()
方法。这个方法的作用是让当前线程阻塞,直到latch
的计数耗尽。在初始化CountDownLatch
时,我们设置了计数为10,表示需要等待10个线程完成操作。每个线程执行完任务后,都会调用countDown()
方法将计数减一。当所有线程都执行完任务后,latch
的计数会变为0,此时await()
方法阻塞的线程会继续执行,输出"运行结束"。"
线程安全的集合类
使用写时拷贝
使用写时拷贝实现线程安全的列表时(比如Java中的CopyOnWriteArrayList),多个线程同时进行读操作不需要加锁。当一个线程进行读操作,另一个线程进行写操作时,读操作会读取原数组的值,而写操作会创建一个原数组的副本进行修改,然后将新数组的引用赋值给原数组。这种方式适合读多写少的场景,因为数组拷贝的开销在大量写操作时可能会变得非常大。同时,如果原数组容量很大,拷贝操作会消耗大量内存和时间,影响效率。
ConcurrentHashMap
- ConcurrentHashMap通过精细化的同步控制和节点状态设计,实现了高效的并发性能。它将CAS操作应用于一些增值操作以减少锁的使用。
- 在扩容时,它采用逐步扩容的方式,而不是一次性复制整个哈希表。当添加新值时,它会在新的哈希表段中直接进行。
- 不同的段(即不同的哈希表部分)之间的修改不会相互影响,因此锁的粒度更细,减少了锁的冲突。
- 读和读之间、读和写之间通常不会加锁,写操作会尽量原子化,以确保读操作读取到的要么是写操作之前的值,要么是写操作完成后的值。当写和写操作发生冲突时,会进行加锁处理。