在进入多线程的学习之前,我们要先明确进程和线程之间的概念。
一.进程
进程的概念
一个运行起来的程序,就可以称为一个进程。进程是操作系统分配资源的最小单位。每一个进程都有一个用来描述该进程的一个结构体(底层是用C语言写的),即进程控制块(PCB),里面包含了很多很多关于该线程的属性。
PCB中的一些关键属性:
1.pid,即进程id,是进程的身份标识。
2.内存指针,一个进程的执行需要知道要执行的指令在哪里,指令依赖的数据又在哪里,所以,就有了内存指针这个概念。内存指针可以定位读写该线程在内存中存储的数据
3.文件描述符表,进程在运行过程中有时要与硬盘进行交互,而硬盘上的数据则是以文件的形式进行组织的。所以,进程在读取硬盘上的数据的时候,要先打开文件,而每打开一个文件,就会把这个文件的信息保存到文件描述符表中,表中的每一个项都代表了一个打开的文件。
4.进程状态,进程有多个状态,在此,我们先介绍最典型的两个:就绪状态和阻塞状态
就绪状态:即该线程已经准备好,随时都可以到cpu上执行
阻塞状态:即该线程此时并不可以到cpu上进行执行
5.进程的优先级,在一台电脑上,往往都会有着好多线程在执行,而它们到cpu上运行的机会也不是一摸一样的,有些进程的优先级高,cpu的资源就会优先给这个进程,让这个线程先执行,有些进程的优先级低,cpu的资源的就不会优先给它。
注:优先级的高低并不能用来绝对衡量各个进程的执行的先后顺序,它只能作为一个参考,即cpu资源分配的一个倾向,但这并不代表一定要完全按照这样执行。在实际进程的执行中,优先级低的进程也有可能在优先级高的进程之前执行
6.进程的上下文,在cpu的进程调度中,一个进程可能在执行到一半时cpu资源被另一个进程抢走,当cpu资源重新回到这个执行到一半的进程上时,这个进程肯定是要沿着上次运行的状态继续往下执行而不能是从头执行,那么,要怎么保存进程执行到哪里了呢?此时,我们便引入了进程上下文的概念。
进程上下文:即操作系统保存的进程运行的全部现场数据,其是靠PC寄存器来记录执行位置的
记录上下文:把cpu中的PC寄存器的值保存到内存中(PCB的对应属性中)
恢复上下文:把PCB中保存的对应属性的值重新填回对应的PC寄存器中
7.进程的记账信息,是一个记录功能,统计每个进程在cpu上运行了多久,如果发现某个进程好久没有吃到cpu分配的资源了,就会给这个进程倾斜一定的资源。
其中,以上的 4~7 点也并称为进程调度。
二.线程
1.线程的概念
在Java中,进程整体是一个比较“ 重 ”的概念,即创建或销毁一个进程时消耗的cpu资源比较大,并不适合频繁的创建与销毁。为了解决上述问题,我们便引入了线程(Thread)的概念,也可以理解为一个轻量级的进程,创建和销毁消耗的资源更少。
进程和线程的概念辨析:
进程是操作系统资源分配的基本单位
线程是操作系统调度执行的基本单位(即上述的进程调度也可以用于线程当中)
一个进程当中往往包含多个线程,而一个进程中的多个线程之间,共用同一份系统资源(内存,文件资源等)
2.创建线程的几个方法
1.直接创建一个实例类继承 Thread类 并重写run 方法
class MyThread extends Thread{
@Override
public void run() {
System.out.println("Hello Thread");
}
}
public class Demo1 {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
}
2.自定义类实现 Runnable 接口,重写 run () 方法;实例化该自定义类对象作为任务,将其传入 Thread 构造方法创建线程对象,调用 start () 启动线程执行任务
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("Hello Thread");
}
}
public class Demo2 {
public static void main(String[] args) {
MyRunnable mr = new MyRunnable();
Thread t = new Thread(mr);
t.start();
}
}
3.创建 Thread 类 的匿名内部类 来创建线程
public class Demo3 {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
System.out.println("Hello Thread");
}
};
t.start();
}
}
4.创建 Runnable 接口 的匿名内部类 来创建线程
public class Demo4 {
public static void main(String[] args) {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello Thread");
}
};
Thread t = new Thread(r);
t.start();
}
}
5.使用 Lambda 表达式实现 Runnable 接口,将线程任务以 lambda 形式传入 Thread 构造方法来创建线程
public class Demo5 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("Hello Thread");
});
t.start();
}
}
6.使用 Callable 与 FutureTask 实现带有返回值的线程任务,将其传入 Thread 构造方法创建线程对象,在调用 start 方法之后,可以通过调用 task.get() 方法获得执行结果
public class Demo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//Callable 相当于定义了一个 “带有返回值” 的任务
//和 Runnable 一样需要交给 Thread对象 去执行
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 1; i <= 100; i++) {
result += i;
}
return result;
}
};
//Thread 并不适配 Callable
//使用 FutureTask 将 Callable 包装成 Runnable 类,适配 Thread
FutureTask task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
//get 操作是获取到 task 的返回值,这个返回值来自 Callable 中的 call 方法
//要等到执行任务的线程执行完毕 get方法 才会得到返回结果,否则会发生阻塞
System.out.println(task.get());
}
}
3.守护线程的概念
守护线程,我们也可以将其理解为后台线程,后台线程往往是JVM自带的线程(比如说垃圾回收线程),它们的存在与否并不影响进程的结束,当整个进程结束时,即使这些线程还没有运行结束,也会随着进程的结束而强制结束。我们自己创建的线程默认都是前台线程(这些线程的存在会影响进程的执行,前台线程不结束整个进程也一般不会结束),可以通过setDeamon 方法进行修改
4.线程等待
有些时候,我们希望一个线程在另一个线程运行结束之后才会接着进行自己的下一步工作,就比如说在生活中商人要在别人付完钱后才会把对应的商品给别人,虽然说我们可以通过 sleep 休眠的时间来控制线程结束的顺序,但这种方法并不科学,有的时候我们是希望一个线程结束,另一个线程就紧接着结束了,此时通过设定休眠时间的方式就不一定靠谱。
为了达成以上目的,线程当中就引入了join这个方法
举个例子,看如下代码:
public class Demo6 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("hello thread");
}
System.out.println("thread线程结束!");
});
t.start();
System.out.println("main线程结束!");
}
}
此时,我们希望的是 t 线程先结束,main 线程再紧接着结束,但结果真的会和我们想的一样吗?当我们运行代码之后,就出现了以下运行结果

根据运行结果来看,这个代码的运行结果和我们预期的结果并不相符,这明明是 main 线程先结束了吧?那么,为了达成我们想要的 t 线程先结束的预期,我们就要用到上面所说的 join 方法来实现,于是,我们便完成了以下的改进代码(注意,join 方法会抛出 InterruptedExceptiono 异常,要进行抛出或 try catch 捕获处理)
public class Demo6 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("hello thread");
}
System.out.println("thread线程结束!");
});
t.start();
t.join();
System.out.println("main线程结束!");
}
}
此时的运行结果如下

可见,main 线程这次确实是在 t 线程执行完毕后紧接着执行结束的
在 main 线程中,调用 t.join 方法,就可以让 main 线程等待 t 线程先结束后再接着执行 main 线程,当 main 线程执行到 t.join 这条语句时,main 线程就会 "阻塞等待" ,一直等到 t 线程执行完毕后再接着执行剩下的代码
join 的空参方法并没有设定等待的最大时间,此时若 t 线程一直不结束,那么 main 线程也要一直等待下去。有时候,我们并不希望这样的效果发生,此时,我们就可以使用 join 带一个整型参数的方法(单位是 ms),规定好该线程等待另一个线程的最大时间,当超过这个时间但另一个线程还没结束时,该线程就会放弃等待,接着执行剩下的代码。
5.线程存活
Java代码创建的 Thread 对象和系统中的线程是一一对应的关系,但是,Thread 对象的生命周期和系统中线程的生命周期是不同的(可能存在系统中的线程已经销毁但 Thread 对象仍然存活的情况)
以如下代码为例
public class Demo7 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
for (int i = 0; i < 10; i++) {
System.out.println(t.isAlive());
Thread.sleep(1000);
}
}
}
运行结果如下
以上述代码的逻辑来说,t 线程的 run 方法 应该 3s 之后就结束了,而当线程入口方法(run 方法)的逻辑结束之时,系统中的对应的线程就随之销毁了,当但我们调用 t.isAlive 方法来查看 t 对象的存活状态时,发现其打印了四次 true !也就是说,在第四次打印之时,t 对象还是存活的!当然,因为系统随机调度的存在,我们并不能保证每次都会打印四个 true ,这是不一定的。但这并不影响其证明了上述括号内容的存在性与真实性。
6.线程终止
线程终止,即让一个线程能够结束,我们只需要让该线程的入口方法(即 run 方法)执行完毕,线程就随之结束了。
在Java中,Thread对象提供了现成的变量,用来直接判定该线程是否终止,并不需要自己进行创建,可以通过 isInterrupted 方法进行判定(线程还未终止结果为 true,已经终止结果为 false,默认为true),可以通过 interrupt 方法进行修改(将 true 改为 false)。
示例
public class Demo8 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
//代码执行到这里有三种选择
//1. 直接退出
//2. 再执行一些语句之后再退出
//3. 忽略 interrupt 方法,接着执行循环内容
//当 interrupt 方法唤醒 sleep 方法时会抛出该异常导致进程终止
//throw new RuntimeException(e);
//若不想导致进程终止可以不抛出异常直接 break 出循环即可
break;
}
}
System.out.println("t 线程结束");
});
t.start();
Thread.sleep(3000);
System.out.println("main 线程尝试终止 t 线程");
t.interrupt();
}
}
注意点:
1.在该示例 Demo 中,创建线程的方法为用 lambda 表达式创建,有一个问题是 lambda 表达式是在线程创建之前就创建好的,所以说,在 lambda 表达式中调用 isInterrupt 方法不能直接通过对象名进行调用(此时该对象 t 还未实例化),要通过 currentThread方法进行调用(哪个线程调用该方法返回的就是哪个线程)
2.在用 interrupt 方法唤醒 被 sleep 等方法让线程进入阻塞状态的线程时会抛出 InterruptedException
3.调用 interrupt 方法会修改 isInterrupted 方法内部的标志位,将其设为 true,但是,在我们这样提前唤醒 sleep 的情况下,sleep 又会在唤醒之后又将 isInterrupted 方法内的标志位重新设置回 false,因此,在这样的情况下,如果上述代码中没有 break 语句在捕获异常后退出循环,就会发现线程还会继续执行。像这样 "非强制性" 的线程终止操作可以让Java中的代码更加灵活,不是 main 线程中让 t 线程终止 t 线程就会立马终止,选择权还是在 t 线程的手上(具体看 t 线程内的代码怎么写)
7.线程休眠
在 Thread 类中,让线程进入休眠的方法是 sleep。
sleep 方法的特性:
1.静态方法,让当前线程主动进入阻塞(休眠)状态,可以指定休眠的时间,休眠时间结束后自动进入就绪状态,等待 cpu 的调度执行
2.因为 cpu 的调度是不可控的,所以,这个方法只能保证实际的休眠时间大于设定的休眠时间(cpu 重新调度到此线程上需要一定的时间)
3.sleep 方法不会释放锁,如果一个线程中持有 synchronized 同步锁,sleep 方法会使线程带着锁一起休眠
4.sleep 方法在休眠过程中被 interrupt 方法提前唤醒会抛出 InterruptedException 异常,且将 isInterrupted 方法内的标志位重新修改为 false,由该线程内部的代码逻辑决定是否真正终止线程(可见第六点线程终止处的解释)
8.线程状态
在上文中,我们提到,线程是一种 "轻量级" 进程,即线程的状态和进程的状态的表示是一样的,也是有 就绪 和 阻塞 两种状态。不过,这是在操作系统的角度上进行看待的,而 Java 线程对操作系统线程进行了封装,而对线程状态而言,Java 中有更细致的表示
六种线程状态如下:
1.NEW :new 了一个 Thread 对象但还没 start,即安排了线程任务但还没开始执行
2.TERMINATED : 线程任务已经执行完毕,内核中的线程已经结束但 Thread 对象仍然存活
3.RUNNABLE : 可工作的,又可以分为正在执行工作或即将开始工作,即就绪状态
以下三种状态都表示线程在阻塞等待其他事情
4.TIMED_WAITING : 指定时间的等待,有时间上限
5.WAITING :未指定时间的等待,死等
6.BLOCKED :由于 锁 导致的阻塞等待
9.线程安全
由于多线程的并发执行代码引起的 bug 就叫做 "线程安全问题",反之,如果一个代码不存在这样的 bug 则称这样的代码是线程安全的
引起线程安全问题的原因:
1.cpu 的随机调度,线程的抢占式执行(根本原因)
2.多个线程同时针对同一个变量进行修改
3.修改操作不是原子的(修改操作可以分为多步执行)
4.内存可见性
5.指令重排序
在我们写代码时,肯定是不希望代码出现 bug 的,那么,我们应该怎么解决上述的 "线程安全问题" 呢?很简单,我们只需要从线程安全问题形成的原因入手,破坏满足这些原因的条件,就可以解决线程安全问题
其中,原因1 是操作系统方面的问题,我们并不能进行修改(当然,如果大佬们有能力重写一套操作系统来避免这个问题的话那就是另一回事了~~),所以,我们要从原因 2 ~ 5 来解决线程安全问题
9.1 使多个线程串行执行(解决原因2)
示例代码如下:
public class Demo9 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count : " + count);
}
}
如上述代码所示,t1 线程 和 t2 线程都对 count 变量自增了 50000 次,按照我们的预期,该程序的输出结果应该是 100000,可当我们运行程序时,发现结果如下图所示

由运行结果可知,count 的值远小于预期的 100000,这也就是我们上文所说的线程安全问题,这个代码的线程安全问题是由原因 1 ~ 3 导致的
由原因2 可知,这是由线程安全问题是由多个线程同时修改一个变量的值导致的,所以,只要我们不让这多个线程 "同时" 修改而是一个接着一个进行修改,即使这多个线程串行执行,就可以解决该线程安全问题。修改后代码如下:
public class Demo9 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t1.join();//在 t1 线程执行完毕后再开始 t2 线程
t2.start();
t2.join();
System.out.println("count : " + count);
}
}
如上,我们只需要让 t2 线程在 t1 线程执行完毕后再开始执行,就可以打破原因2的形成条件,从而解决线程安全问题,得到正确的结果

可是,在实际运用中,我们往往不会使多个线程穿行化执行来解决线程安全问题,因为这样就完全失去了多线程的并发执行的意义,使多线程退化为单线程,执行效率低下
9.2 使修改操作变为原子的(加锁,解决原因3)
在介绍这个解决线程安全问题的方法之前,我们先了解一下 Java 中锁的概念与特性
9.2.1 锁的概念
锁是控制多线程访问共享资源的同步机制,可以保证同一时间,最多只有一个线程可以对共享资源进行修改,可以用来解决由原子性带来的线程安全问题
9.2.2 锁的特性
1.互斥性 : 一把锁同一时刻只能被一个线程持有,其他线程想要获取锁,只能阻塞等待
2.可重入性 : 同一个线程已经持有这个锁,再次获取这把锁不会发生阻塞,不会发生死锁问题
3.阻塞等待 : 抢锁失败的线程进入阻塞队列,让出 CPU,不消耗算力,等锁释放后 JVM 唤醒竞争
通过上述对 锁 的概念和特性的介绍,我们同学们已经对锁有了一个初步的认知,接下来,我们就来演示如何用锁来解决该线程安全问题
我们已知,该线程安全问题是由于 count++ 操作的非原子性造成,即 count++ 还可以分为以下三步:
1.load :从内存中读取 count 的值到寄存器中
2.add :在寄存器中对 count 的值进行自增
3.save :将寄存器中自增后的值写回内存之中
而由于 cpu 的资源调度是随机的,在 t1 线程执行到 count++ 中的 add 操作时(假设是第一次自增操作,load 读取到的值为 0),cpu 的执行权可能就到了 t2 线程上,当 t2 线程从内存中读取 count 的值时,读取到的还是未修改过的值(值为 0),add 操作自增后为 1,再写回内存中。此时 cpu 的执行权又给到 t1 线程,而 t1 线程接着执行未完成的 save 操作,也将 1 写入内存中。像这样,虽然 t1 和 t2 线程都执行了一次自增操作,但 内存中的 count 的值却是 1,相当于只执行了一次自增,而另一次自增操作则被覆盖掉了。这也就是由于自增操作的非原子性导致的线程安全问题。
利用锁来解决由于自增操作的非原子性而导致的线程安全问题也很简单,只要将希望变成原子性的操作(此时为自增操作)放在锁中(synchronized)即可,修改后的代码如下:
public class Demo9 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count : " + count);
}
}
在 synchronized 后面的小括号中的参数为锁对象(可以传入任意类型的变量作为锁对象),不同的锁对象对应着不同的锁,而要解决多个线程之间的线程安全问题,我们要用同一把锁(即要传入的锁对象要相同)将其中导致线程不安全的非原子性操作锁起来,使其变为原子性的操作。像这样,我们就能得到正确的结果。

9.2.3 死锁问题
死锁问题分为以下两种:
1.一个线程,一把锁,连续加锁多次
2.两个线程,两把锁,每个线程获取到一把锁之后,接着尝试区获取对方的锁
死锁形成的原因
1.互斥性: 一把锁同一时刻只能被一个线程持有,其他线程想要获取锁,只能阻塞等待
2.不可剥夺、不可抢占性:当一个线程持有锁时,这个锁就不能被其他线程剥夺与抢占
3.请求与等待:未持有锁的线程只能等待持有锁的线程主动释放锁后才能尝试获取锁
4.循环等待:对应上述第二种情况,在两个线程分别持有一把锁的情况下在去尝试获取对方的锁,就回造成循环等待,谁都获取不到对方的锁,进而一直处于等待状态
解决死锁问题的方法
1.避免锁的嵌套 -> 打破原因3 (在Java中由于锁的重入性已经帮助我们解决了这第一种情况的死锁问题,不过在C++等编程语言中要注意避免该问题)
2.规定加锁的顺序 -> 打破原因4
9.3 添加 volatile 关键字(解决原因4 和 原因5)
内存可见性问题形成的原因
每个线程都有一个属于自己的 "工作内存"(并不是实际意义上的内存,实际上指的是 cpu 的寄存器),同时这些线程又都共享这同一个 "主内存"(实际意义上的内存)。当一个线程循环从主内存读取变量时,会同时将主内存中读取到的数据拷贝到自己的工作内存(寄存器)中。当线程发现循环读取的变量长时间都是同一个值时,就不会再去主内存中读取数据,而是直接在自己的工作内存(寄存器)中读取。而如果另一个线程修改主内存的数据时,也是先修改自己工作内存(寄存器)中的数据再拷贝到主内存中。而此时由于第一个线程仍然在读取自己的工作内存(寄存器)中的数据,也就感知不到主内存的数据已经发生了变化,这个问题就是内存可见性问题
解决内存可见性问题的方法很简单,只需要我们在可能发生内存可见性问题的变量的前面添加上 volatile 关键字 即可,使用这个关键字修饰的变量,就属于 "易变" 、 "易失" 类型的变量,当线程读取这类型变量时,就必须到主内存中读取数据了(不会优化为从工作内存中读取数据)。
指令重排序问题形成的原因
在介绍指令重排序问题之前,我们先来讲解一下该问题的引子——单例模式
单例模式其实就是程序员大佬们编写好的一种设计模式,就类似于围棋棋谱类的东西,针对一些典型的场景的代码的书写给出了一些指导方案。当我们学会了这些设计模式之后,在面对相同或相似的场景之时,我们便能快速上手,写出较为不错的代码
两种单例模式
1.饿汉模式
饿汉模式,顾名思义,就代表了急切,就要尽早地对其中的成员变量进行初始化,例如以下代码所示
class Singleton{
private static Singleton instance = new Singleton(100);
public static Singleton getInstance(){
return instance;
}
private Singleton(){
}
private Singleton(int n){
}
}
public class Demo10 {
public static void main(String[] args) {
Singleton t1 = Singleton.getInstance();
Singleton t2 = Singleton.getInstance();
System.out.println(t1 == t2);
}
}
我们可以看到,该代码中的 Singleton 类在加载时就完成了成员变量的初始化,而且,其中的构造方法都是以 private 修饰的,外部无法创建新的 Singleton 对象,只能通过 Singleton 类提供的 getInstance 方法来获取实例对象
2. 懒汉模式
懒汉模式,顾名思义,就代表了懒惰,与饿汉模式急切地初始化成员变量相对,懒汉模式就是尽量晚地创建实例甚至是不创建实例了(当没有代码调用这个实例的时候就不会创建),例如以下代码所示
class SingletonLazy{
private static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance == null){
instance = new SingletonLazy();
return instance;
}
return instance;
}
private SingletonLazy(){
}
}
public class Demo11 {
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
}
}
我们通过上述代码可以发现,只有我们第一次去创建一个 SingletonLazy 实例时,静态成员变量 instance 才会被赋值(而不是在程序刚启动的时候),这也就是尽可能晚地创建实例对象。
那么,说到现在,肯定就有同学要疑惑了,这单例模式和本文要讲的多线程又有什么关系呢?别急别急,且听我娓娓道来。
现在,我们先来思考一个问题,上述的两份代码(饿汉模式代码和懒汉模式代码),是否是线程安全的呢?如果不是,我们又该怎么让它们变成线程安全的呢?
通过对上述两份代码的分析我们可以知道,饿汉模式中的 instance 成员变量是程序一加载时就已经创建好的,后续的代码只涉及 return 的读操作,不涉及变量修改,也就是说饿汉模式的代码是线程安全的。
而懒汉模式呢?它是否是线程安全的呢?我们分析代码发现,懒汉模式的代码中的 instance 成员变量是当我们第一次调用 getInstance 方法时才创建的, 这就有可能涉及到了多线程的修改,也就是说,懒汉模式的代码是线程不安全的。这时,我们就可以通过加锁来使其线程安全。加锁代码如下
class SingletonLazy{
private static volatile SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance(){
synchronized (locker){
if(instance == null){
instance = new SingletonLazy();
return instance;
}
return instance;
}
}
private SingletonLazy(){
}
}
此时,这份懒汉模式的代码确实是线程安全了,但是,这样的代码是否又会引发新的问题呢?
通过对代码的分析我们发现,当我们将 instance 实例创建好了之后,后续我们再调用 getInstance 方法时,都是直接执行 return 操作,也就是说,此时的 getInstance 方法变成了纯粹的 "读" 操作,也就不会涉及到线程安全问题。但是,当我们每次调用 getInstance 方法时,都会触发一次加锁操作,这就会导致线程之间的相互阻塞,影响程序的执行效率。为了解决因为过度加锁而导致的效率降低问题,我们就要做到按需加锁,在真正涉及到线程安全问题时再加锁,不涉及的时候就不加锁。那么,在上述的代码中,我们要怎么做到按需加锁呢?其实也很简单,我们只需要在加锁之前先判断 instance 是否已经被创建实例,如果已经创建,那么就直接执行 return 操作。优化后的代码如下所示:
public static SingletonLazy getInstance(){
if(instance == null){
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
return instance;
}
}
}
return instance;
}
此时,我们只有在 instance 未创建实例时才会进行加锁并创建实例的操作,也就避免了非必要情况下加锁所带来的效率降低问题。
也许有的同学会有疑问,这个代码此时的两个 if 判断的条件是一样的啊,这难道不是代码重复,没意义的吗?这就是因为我们之前接触的都是单线程的代码所带来的疑惑。确实,在单线程代码中,连续两个相同的 if 条件判断是无意义的(因为单线程中只有一个执行线程,连续两个 if 条件判断的结果是一样的)。不过,我们此时的代码是多线程代码,在两个 if 条件判断之间,就可能存在其他线程将 instance 变量的值给修改了,也就导致了两次 if 条件判断的结果不同。
诚如上述代码,虽然说这两个 if 条件判断的语句是一样的,但它们所起的作用是截然不同的。第一个 if 条件判断是判断 instance 变量是否已经被实例化从而判断接下来是否进行加锁操作,保证了程序不会因没意义的加锁所导致的执行效率降低问题。而第二个 if 条件判断则是判断是否有其他线程在第一个 if 条件判断 和 加锁操作 之间创建了 instance 实例从而导致 instance 实例被重复创建所导致的线程安全问题。
到这里还没完,我们再仔细分析,上述的代码仍然还是存在问题的。我们可以思考一下,这代码是否会出现 "内存可见性" 问题呢?这完全是有可能存在的,因为编译器的优化是非常复杂的操作,为了稳妥起见,我们可以用 volatile 关键字来修饰 instance 成员变量,从根本上杜绝 "内存可见性" 问题。
但是,这里最重要的问题是可能存在的 "指令重排序" 问题。皇天不负有心人,我们兜兜转转,终于回到了我们这小标题中的 "指令重排序" 问题。
指令重排序问题
"指令重排序" 问题也是编译器优化的一种体现形式,编译器会在保持原有代码逻辑不变的前提下,调整代码的执行顺序,以达到提升性能的效果。
就比如说上述的 instance = new SIngletonLazy(); 操作,就会在 JVM 分为以下三步:
1.申请内存空间 2.在空间上构造对象 3.将内存空间的首地址赋值给引用变量
在正常情况下,这三个步骤按照 1 -> 2 -> 3 的顺序来执行的。但是,在指令重排序下,就有可能按照 1 -> 3 -> 2 这样的顺序来执行。在单线程中,这三个步骤的执行顺序是无所谓的,但是在多线程的情况下,就可能会出现 bug !
举个例子,一个线程(线程1)按照 1 -> 3 -> 2 这样的顺序执行时,当这个线程执行完 步骤3 之后,cpu资源就被调度到了另一个线程(线程2)上,在线程2眼中,instance 变量的值已经不为空了,那么当线程2 用 instance 变量去执行相关操作时,就有可能引发一系列问题,因为此时的 instance虽然已经指向了一个地址,但这地址指向的对象却还是未初始化过的!这也就是由于 "指令重排序" 而引起的线程安全问题。
而要解决 "指令重排序" 带来的问题也很简单,想必有的同学已经想到了,没错!就是你们所想的那样,就是用 volatile 关键字来修饰可能出现 "指令重排序" 问题的变量,这样操作之后,关于该变量的读取和修改操作就不会触发 "指令重排序"。
volatile 关键字的两个功能小结:
1. 确保每次读取变量操作都是从内存中读取
2.关于该变量的读取和修改操作不会触发重排序
10. 生产者和消费者模型
在介绍生产者和消费者模型之前,我们要先了解两个线程方法:wait 和 notify。这两个方法是用来协调线程之间的执行逻辑的顺序的。
10.1 线程的等待和唤醒
10.1.1 wait 等待方法
说到线程的等待方法,或许,已经有同学回想起来了,我们在前面不是已经学过一个线程等待方法 join了吗?怎么这里又来一个线程等待方法 wait 呢?同学们先别着急,我们这就来说明他两的区别。
wait 和 join 虽然都是线程等待方法,但它们的使用时机和用途却是截然不同的。join 方法的功能是等待目标线程执行完毕,其中并不涉及到锁的操作。而 wait 方法的功能则是让当前线程释放锁,进入等待队列,等待被其他线程唤醒,wait 方法的 使用前提 是必须处于必须处于 锁 的synchronized 代码块中。
和 join 类似,wait 方法也提供了 "死等" 和 "超时时间" 的版本,wait 引入超时时间后,就和 join 方法一样,最多等那么久,等了这么久如果还没 notify 就不等了,就会接着往下执行。
10.1.2 notify 唤醒方法
notify 方法 :随机唤醒一个正在 等待(wait)的线程
notifyAll 方法 :唤醒正在 等待(wait)的全部线程
注意:notify 方法只能唤醒 "同一个锁对象" 的等待线程!notify 方法的使用要在 wait 方法之后,否则就是无效代码。
注:wait 方法通常都要搭配着 while 循环 (为了进行二次确认) 进行使用,在每次 wait 返回都重新循环校验条件是否成立,可以有效避免 虚假唤醒、多线程竞争后唤醒条件已经失效 等问题
接下来,我们再来学习下阻塞队列
10.2 阻塞队列
在之前的数据结构中,我们已经学习过了 "队列" 和 "优先级队列" ,而所谓的阻塞队列,其实也就是一种更复杂的队列。
阻塞队列的特性
1.线程安全:阻塞队列内部通过加锁和两个条件变量(notEmpty,notFull)来实行并发控制,是线程安全的
2.阻塞特性:
当队列为空时,尝试出队列,出队列操作就会被阻塞,直到其他线程添加元素为止
当队列为满时,尝试入队列,入队列操作就会被阻塞,直到其他线程弹出元素为止
阻塞队列一个最主要的应用场景就是用来实现 "生产者消费者模型"
10.3 生产者消费者模型的两个重要优势
1.解耦合(不一定是两个线程之间,也可能时两个服务器之间)
例如两个服务器之间直接相互请求与相应的示例图如下图所示

像这样两个服务器之间直接相互访问的场景下,如果服务器A直接访问服务器B,那么此时服务器A与服务器B的耦合就比较高,因为为了实现 A 直接访问 B,在编写 A 的代码时,多多少少会涉及到一些与 B 相关的逻辑。同理,在编写 B 的代码时,也多多少少会有一些与 A 相关的逻辑。
那么,如果在这两台服务器之间引入了阻塞队列来进行中转又会怎样呢?示例图如下所示

像这样,服务器A和服务器B通过阻塞队列进行交互,就变成了 A与队列交互 和 B与队列交互,此时 A 和 B 就不再直接交互了,A的代码中就不会再有关于B的逻辑,B的代码中也不会有A的逻辑了,我们这就实现了 A 与 B 的解耦合
但是,此时就会有同学有有疑问了,你这样通过阻塞队列来进行服务器A和服务器B的交互时,你 A 与 B 之间的耦合是降低了,但是你 A 与 阻塞队列 、B 与 阻塞队列的耦合度上升了啊?这样也算降低耦合吗?在这里,我想说的是,降低耦合的目的,是为了让后续对代码修改更方便,而阻塞队列作为一个 A 与 B 之间的交互桥梁,一般不会进行修改,也就是说 服务器A 与 阻塞队列、服务器B 与 阻塞队列的耦合不会影响后续代码的修改操作。
之前服务器A 与 服务器B 直接交互带来的耦合问题就是,在后续修改 A 的代码的时候也要同时去修改 B 的代码
2.削峰填谷
像这样 A 与 B直接交互的代码

当 A 这边遇到流量激增时,A 会将每一个请求都转发给 B,B 也会同时承担相应的流量激增,服务器B 就很有可能因为这个而崩溃
而像 A 与 B 通过阻塞队列进行交互的代码

则是在 A 遇到流量激增时,会先把每一个请求放入阻塞队列中,当阻塞队列放满了服务器A 放入请求的操作就会被阻塞起来,而相应的,服务器B 依旧是可以按照自己的节奏去处理在阻塞队列中的请求,也就不会出现服务器B 崩溃的情况
10.4 生产者消费者模型付出的代价
1.在引入阻塞队列之后,代码的整体结构会变得更加复杂,管理起来会更加麻烦
2.代码的执行效率会受到影响
10.5 用阻塞队列实现生产者消费者模型
阻塞队列的使用
在Java标准库中,提供了现成的阻塞队列 -- BlockingQueue
接下来,我们就来演示以下阻塞队列的用法,演示代码如下:
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
BlockingDeque<String> queue = new LinkedBlockingDeque<>(100);
for (int i = 0; i < 100; i++) {
queue.put("aaa");
}
System.out.println("队列已经满了");
System.out.println("再次尝试 put 元素");
queue.put("aaa");
System.out.println("put 成功");
}
}
该代码中创建 BlockingQueue 实例时传入的参数是指定该阻塞队列的最大容量,即该队列最多只能放 100 个元素
当我们运行该代码时,发现执行结果如下图所示

我们可以发现,该代码的运行并没有结束,而是停留在了打印 "put 成功" 之前,这是为什么呢?这又不得不说到阻塞队列的阻塞特性了。我们知道,该阻塞队列的最大容量是 100 ,但在 for 循环中已经向该阻塞队列放入了 100 个元素,此时这个阻塞队列就已经被放满了,再执行 put 操作时,就会发生阻塞,直到该阻塞队列有元素被 take 出去才会接着执行 put 操作
阻塞队列的实现
在了解了阻塞队列的功能之后,我们就可以自己去试着实现一个自己的阻塞队列,实现代码如下所示
class MyBlockingQueue {
private String[] data = null;
private int head = 0;//队头
private int tail = 0;//队尾
private int size = 0;//存储的元素个数
public MyBlockingQueue(int capacity){
data = new String[capacity];
}
public void put(String elem) throws InterruptedException {
synchronized (this){
//队列是满的,生产者就要进行等待
while (size >= data.length){
this.wait();
}
//计算新的队尾下标
data[tail] = elem;
tail++;
if(tail >= data.length){
tail = 0;
}
size++;
//生产者生产完元素后唤醒消费者接着去消费元素
this.notify();
}
}
public String take() throws InterruptedException {
synchronized (this){
//队列是空的,消费者就要进行等待
while (size == 0){
this.wait();
}
//记录返回元素并计算新的队头下标
String ret = data[head];
head++;
if(head >= data.length){
head = 0;
}
size--;
//消费者消费元素后唤醒生产者去接着生产元素
this.notify();
return ret;
}
}
}
生产者消费者模型代码实现
生产者消费者模型的原理就是 生产者往阻塞队列中生产元素,消费者从阻塞队列中取出元素进行消费,基于此,我们可以写出以下代码
public class Demo13 {
public static void main(String[] args) {
BlockingDeque<Integer> queue = new LinkedBlockingDeque<>(1000);
Thread producer = new Thread(() -> {
int n = 0;
while (true){
try {
queue.put(n);
System.out.println("生产者生产元素"+n);
n++;
//Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"producer");
Thread consumer = new Thread(() -> {
while (true){
Integer n = null;
try {
n = queue.take();
System.out.println("消费者消费元素"+n);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"consumer");
producer.start();
consumer.start();
}
}
像以上代码所示,生产者线程往 阻塞队列 queue 中放元素,而消费者线程则从 阻塞队列 queue 中消费元素,这就是最基础的生产者消费者模型,运行结果如下图所示

因为我们在消费者线程中加了 sleep 休眠 ,所以生产者线程会在消费者线程休眠期间快速生产1000个元素放满 阻塞队列 queue ,然后发生阻塞。接下来就是消费者线程消费一个元素,生产者线程就生产一个元素。这也就是所谓的生产者消费者模型。
11. 线程池
线程池,与常量池类似,线程池中的线程也是在 Java 程序最初构建的时候就已经准备好的,等到程序运行的时候,这些线程也就加载到内存中了。最初我们因为频繁创建和销毁进程消耗了大量的cpu 资源,所以引入了线程的概念,然而,随着互联网的发展,我们对于性能的要求越来越高,频繁创建和销毁线程而产生的开销,也慢慢变得难以接受了。发明线程池的目的,就是为了让我们能够节省出创建和销毁线程的开销。
在 Java 标准库中提供了可以直接使用的线程池 ThreadPoolExcutor,该类的创建所需要的参数比较多且较为重要,下面我们来一一讲解
ThreadPoolExcutor 类 参数的含义
1. int corePoolSize 核心线程数
这表明这个线程池中至少有多少个线程(线程的最少数目),线程池一创建,这些线程也就随之创建,直到整个线程池销毁,这些线程才会销毁
2.int maximumPoolSize 最大线程数
这表明这个线程池中最多能创建多少个线程(核心线程数 + 非核心线程数),其中非核心线程数在任务繁忙时就创建,任务不繁忙时就销毁
3.long keepAliveTime 非核心线程允许空闲的最大时间
非核心线程并不是一空闲下来就立马销毁,在其中还有一个允许空闲的最大时间,如果一个非核心线程空闲超过最大时间之后,这个非核心线程就会被销毁
4.TimeUnit unit
枚举类型,在其中将各个时间单位通过枚举保存
5.BlockingQueue<Runnable> workQueue 工作队列
线程池本质上就是一个生产者消费者模型,调用 submit 方法就是在往工作队列中生产任务,我们可以通过这个参数来指定要使用的工作队列的数据结构(如数组、链表等),并指定容量大小(capacity),指定是否要带有优先级/比较规则
6. ThreadFactory threadFactory 工厂模式
工厂模式(也是一种设计模式,与单例模式并列),主要是用来统一地构造并初始化线程,用来弥补构造方法的缺陷(有时候不同的构造方法之间并不能构成重载,但这两种构造方法又的确是不同的版本时,就要用到工厂方法进行解决)。工厂方法的核心就是通过静态方法去把构造对象(new) 的过程(各种属性初始化的过程)进行了封装,通过提供多组静态方法来实现不同的构造方法。提供工厂方法的类,就称为工厂类。
7. RejectedExecutionHandler handler 拒绝策略(整个线程池的七个参数中最重要最复杂的参数)
任务队列是一个阻塞队列,当队列满了时,我们再调用 submit 方法往里面放入新的任务时,就会产生阻塞,而我们一般是不希望程序阻塞太多的(线程就没办法去做别的事情了,用户就会迟迟拿不到相应,导致使用体验不好)。对于线程池来说,当调用 submit 方法发现任务队列已经满了时,并不会真的阻塞,而是执行拒绝策略相关的代码。
四种拒绝策略
ThreadPoolExecutor.AbortPolicy :线程直接抛出异常(线程可能无法继续工作)
ThreadPoolExecutor.CallerRunsPolicy :让调用 submit 方法的线程自行执行该任务
ThreadPoolExecutor.DiscardOldestPolicy :丢弃队列中最老(最先放入)的任务
ThreadPoolExecutor.DiscardPolicy :丢弃队列中最新的任务,即当前 submit 的这个任务
12. 定时器
计时器的定义
定时器就和闹钟一样,时间到了,就会去执行一些逻辑。在 Java 标准库中提供有已经定义好的定时器 Timer 。当我们创建好一个 Timer 实例后就可以通过该实例调用 schedule 方法来添加任务(TimerTask)并指定在多长时间之后(单位为毫秒)执行。其中,描述任务的方式稍有不同,正常描述任务是用 Runnable ,而在定时器中则是将 Runnable 封装成了 TimerTask 来进行使用。
简单实用定时器的示例代码
public class TimerDemo {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 3000");
}
},3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hellp 2000");
}
},2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 1000");
}
},1000);
System.out.println("hello main");
}
}
输出结果如下:

注:此时 Timer 工作线程(前台线程)会持续阻塞等待任务,控制台不会停止运行。
自己实现一个简单计时器
实现一个计时器可以简单分为四个步骤:
1.创建一个类来表示任务
2.使用优先级队列(小根堆,仅根据任务的执行时间来比较大小,要实现 Comparable 接口并重写compareTo 方法 或 传入一个比较器)来管理多个任务
3.实现 schedule 方法,将任务添加到队列里面去
4.在构造方法中创建一个线程,负责执行队列中的任务
所完成的代码和注意事项如下:
class MyTimerTask implements Comparable<MyTimerTask>{
private Runnable task;
private long time;
public MyTimerTask(Runnable task,long time){
this.task = task;
this.time = time;
}
@Override
//重写比较方法,依靠等待时间来进行排序并放入小根堆中
public int compareTo(MyTimerTask o) {
return (int)(this.time - o.time);
}
//获取一个任务的等待时间
public long getTime(){
return time;
}
public void run(){
task.run();
}
}
//自己实现的定时器
class MyTimer{
//小根堆,用来存储任务(依靠等待时间的大小进行排序)
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
private Object locker = new Object();
public void schedule(Runnable task,long time){
//涉及到修改堆,加锁防止线程安全问题
synchronized (locker){
MyTimerTask timerTask = new MyTimerTask(task,System.currentTimeMillis()+time);
queue.offer(timerTask);
//添加任务后唤醒线程
locker.notify();
}
}
public MyTimer(){
Thread t = new Thread(() -> {
try {
while (true){
synchronized (locker){
// 如果堆为空,那么进行等待
// 如果使用if条件判断并在不符合条件时continue退出本次循环,
// 就会触发“忙等”,会大量消耗cpu资源
// if(queue.isEmpty()){
// continue;
// }
while(queue.isEmpty()){
locker.wait();
}
//从堆顶取出最早执行的任务
MyTimerTask task = queue.peek();
//如果还没到该任务执行的时间,那么就进行等待
if(System.currentTimeMillis() < task.getTime()){
//continue; //和上文一样,会触发“忙等”问题
//不能使用sleep方法进行等待
// 一是因为sleep只能等待完对应的时间如果有时间更早的任务被添加进来的话不能及时执行。
// 二是因为sleep休眠是不会释放锁的,这就会导致其他线程调用schedule方法时会产生阻塞
locker.wait(task.getTime() - System.currentTimeMillis());
}else {
//反之则运行该任务并从堆中弹出该任务
task.run();
queue.poll();
}
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
public class MyTimerDemo {
public static void main(String[] args) {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 3000");
}
}, 3000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 2000");
}
}, 2000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 1000");
}
}, 1000);
}
}
以上就是本片文章的全部要点,欢迎各位读者阅读与指正 !!!
1万+



被折叠的 条评论
为什么被折叠?



