当前位置:网站首页>两万字带你掌握多线程
两万字带你掌握多线程
2022-07-04 03:53:00 【Lockey-s】
进程和线程
线程
因为计算机的发展,系统支持多任务了。所以就需要并发编程。通过多进程,是完全可以实现并发编程的,但是也有个问题:如果要频繁的创建/销毁进程,就需要分配内存,打开文件,就需要释放资源,释放内存。执行任务的成本较高,主要是因为资源的 创建 和 释放 不是高效的,所以成本较高。
实现并发编程中,解决 创建 和 销毁 消耗资源大的问题,有两个方法:
- 进程池:进程池可以解决问题,提高效率。但是也有问题,池子里的闲置进程,不使用的时候也在消耗系统资源,消耗的系统资源太多了。
- 使用线程来实现并发编程:线程比进程更轻量,每个进程可以执行一个任务,每个进程也能执行一个任务(执行一段代码),也能够并发编程,创建线程的成本比创建进程要低很多,销毁线程的成本也比销毁进程低很多,调度线程的成本也比调度进程低很多。
- 但是线程不是越多越好,如果线程多了,这些线程可能要竞争同一个资源,这个时候,整体的速度就受到了限制,因为整体硬件资源是有限的。
线程和进程的区别和联系
- 进程包含线程:一个进程里可以有一个线程,也可以有多个线程。
- 进程和线程都是为了处理并发编程这样的场景。但是进程有问题,频繁的创建和释放的时候效率很低,相比之下,线程更轻量,创建和释放的效率更高。因为线程少了申请和释放的过程
- 操作系统创建进程,要给进程分配资源,进程是操作系统分配资源的基本单位。操作系统创建的线程,是要在 CPU 上调度执行,线程是操作系统调度执行的基本单位。
- 进程具有独立性,每个进程有各自的虚拟地址空间,一个进程挂了,不会影响到其它进程。同一个进程中的多个线程,公用同一个内存空间,一个线程挂了,可能影响到其他线程,甚至导致程序崩溃。
- 线程比进程轻量的原因:进程重量重在资源的申请释放。线程是包含在进程当中的,一个进程中的多个线程共用同一份资源。只是创建第一个进程的时候(由于要分配资源),成本是相对高的,后续在这个进程中再创建其他线程,这个时候成本就要更低一些,因为不必再分配资源了。
- 可以把进程比作一个工厂,线程就是生产线,线程多了之后,生产效率就高了,如果再建一个工厂来生产,效率也可以变高,但是资源花费大,所以通过增加一条生产线(线程)来提高效率的话,资源花费就很小。
并发编程
Java 中并发编程主要用多线程,不同于其他语言。go 语言是通过多协程来实现,erlang 是通过 actor 模型实现并发, js 是通过定时器 + 实际回调的方式实现并发。
Java 进行多线程编程
Java 提供了一个 Thread 类,来表示/操作线程。Thread 类也可以视为是 Java 标准库提供的 API。创建好的 Thread 实例,其实和操作系统中的线程是一一对应的关系。操作系统提供了一组关于线程的 API(不过是 C/C++ 写的),java 进一步封装,就成了 Thread 类。
最基本的多线程代码
通过 Tread 来创建,不过这里是创建一个自己的 MyTread 并重写 run 方法来看线程的情况。run 方法就描述了线程内部要执行什么代码代码如下:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello thread");
}
}
public class TestDemo {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
run 方法里面描述了线程内部要执行哪些代码,每个线程都是并发执行的(各自执行各自的代码,就是告诉线程,要执行的代码是什么)。不是一定义这个类,一写 run 方法,线程就创建出来,相当于有活了,但是还没干。调用 new 的对象的 start 方法,才是真正的创建了线程。这里可以创建很多个线程,这些线程都是同一个进程内部创建的。运行结果如下:
最简单的并发编程代码
一个进程中,至少会有一个线程,在一个 Java 进程中,至少会有一个调用 main 方法的线程,自己创建的 t 线程,和自动创建的 main 线程,就是并发执行的关系(宏观上看起来是同时执行)。这里的代码就是 MyThread 和 main 一起并发执行。代码如下:
class MyThread2 extends Thread {
@Override
public void run() {
while (true) {
System.out.println("Thread!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class TestDemo2 {
public static void main(String[] args) {
MyThread2 t = new MyThread2();
t.start();
while (true) {
System.out.println("Main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
这里的结果就是 Thread!和 Main 交替输出,每次输出和上次输出差不多相隔一秒:
在阻塞一秒之后,先唤醒 Thread 还是 Main,是不确定的。对于操作系统来说,内部对于线程之间的调度顺序,在宏观上可以认为是随机的(抢占式执行)。
Runnable 接口
Runnable 就是在描述一个任务,然后重写 run 方法,就是要执行的任务内容。然后通过 Runnable 把描述好的任务交给 Thread 实例:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
}
public class TestDemo3 {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start();
}
}
运行结果如下:
通过匿名内部类
匿名继承 Thread 类
通过匿名内部类也可以实现 :
public static void main(String[] args) {
Thread t = new Thread() {
@Override
public void run() {
System.out.println("Thread");
}
};
t.start();
}
这里的匿名内部类是继承自 Thread 类,同时重写了 run 方法,同时再 new 出这个匿名内部类的实例。运行结果如下:
匿名 Runnable
和匿名 Thread 一样,也可以实现 Runnable 接口:
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread");
}
});
t.start();
}
这里 new 的 Runnable 针对这个创建的匿名内部类,同时 new 出的 Runnable 实例传给 Thread 的构造方法。
Thread 和 Runnable 选择
通常认为选择 Runnable 来写更好一些,能够做到让线程和线程执行的任务,更好的解耦。写代码注重 高内聚,低耦合。Runnable 单纯的只是描述了一个任务,至于这个任务是要通过一个进程来执行,还是线程池来执行,还是协程来执行,Runnable 并不关心,Runnable 里面的代码也不关心。
lambda 表达式
在使用多线程的时候,也可以写成 lambda 表达式,这种表达式方法更简单:
public static void main(String[] args) {
Thread t = new Thread(()-> {
System.out.println("Thread");
});
t.start();
}
多线程对时间的优化
我们来计算两个变量的自增,从 0 自增到 10亿,然后对比时间。一种是串行执行,一种是并发执行。不过要注意的是,多线程当中的时间戳代码是在 main 线程中,所以要等到 t1 和 t2 都执行玩然后再计时。所以通过 join(); 方法来等待计时结束,代码如下:
public static void serial() {
long beg = System.currentTimeMillis();
long a = 0;
for (int i = 0; i < 10_0000_0000; i++) {
a++;
}
long b = 0;
for (int i = 0; i < 10_0000_0000; i++) {
b++;
}
long end = System.currentTimeMillis();
System.out.print((end-beg)+"ms");
}
public static void concurrency() throws InterruptedException {
long beg = System.currentTimeMillis();
Thread t1 = new Thread(()->{
long a = 0;
for (int i = 0; i < 10_0000_0000; i++) {
a++;
}
});
t1.start();
Thread t2 = new Thread(()->{
long b = 0;
for (int i = 0; i < 10_0000_0000; i++) {
b++;
}
});
t2.start();
t1.join();
t2.join();
long end = System.currentTimeMillis();
System.out.print((end-beg)+"ms");
}
public static void main(String[] args) throws InterruptedException {
serial();
System.out.println();
concurrency();
}
运行结果如下:
可以看出,多线程的运行效率确实比串行要高,不过如果数很小的时候,就不适合用多线程了,因为创建线程也需要时间,如果很小的话,用串行就够了。多线程适用于 CPU 密集型的程序,程序要进行大量的计算,使用多线程就可以更充分的利用 CPU 的多核资源。
Thread 类的属性和方法
Thread(String name) 创建线程对象并命名
在创建完 Thread 对象之后,可以对其进行命名。命名之后在调试的时候很方便:
public static void main(String[] args) {
Thread t1 = new Thread(()-> {
while (true) {
System.out.println("Thread t1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"Thread t1");
t1.start();
Thread t2 = new Thread(()-> {
while (true) {
System.out.println("Thread t2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"Thread t2");
t2.start();
}
运行结果如下:
使用 jconsole 来查看 Java 进程
在对进程命名之后,就可以从 jconsole 来查看进程了。
- 从 JDK 的 bin 目录中找到 jconsole :

- 然后打开选择运行的进程

- 可能会弹出不安全的连接,继续连接就好

- 然后点线程

- 然后就能看到自己命名的线程了,很方便调试

- 显示线程的代码执行到哪里了

是否后台线程 isDaemon
如果线程是后台消息称,就不影响进程退出。如果线程不是后台线程(前台线程),就会影响到进程退出。就像创建的 t1 和 t2:
- 如果都是前台线程,即使 main 方法执行完毕,进程也不能退出,得等 t1 和 t2 都执行完,整个进程才能退出。
- 如果都是后台线程,此时如果 main 执行完毕,这进程就直接退出,t1 和 t2 就被强行终止了。
是否存活 isAlive
Thread t 对象的生命周期和内核中对于的线程,生命周期并不完全一致。创建出对象之后,在调用 start 之前,系统当中是没有对应线程的,在 run 方法执行完了之后,系统当中的线程就销毁了。但是 t 这个对象可能还存在。通过 isAlive 就能判断当前系统的线程的运行情况。
start
start 决定了系统中是不是真的创建出线程,run 只是一个普通的方法,描述了任务的内容。代码如下:
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (true) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
运行结果如下:
那么把 start 换成 run :
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (true) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.run();
}
运行结果如下:
发现 run 也能输出 Thread 。但关键是 run 并没有创建线程,这里的 run 是输出了任务的内容,而不是创建线程。Thread 则是在操作系统当中创建线程。下面用一个更简单理解的代码来演示:
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (true) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
while (true) {
System.out.println("Main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果如下:
因为 start 是创建线程,所以会和 main 线程并发执行。如果换成 run 的话:
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (true) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.run();
while (true) {
System.out.println("Main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果如下:
只输出了任务内容,没有创建线程,只是从上往下执行。
中断线程
就是让线程停下来,线程停下来的管,是要让线程对应的 run 方法执行完。(还有一个特殊情况:是 main 这个线程,对于 main 来说,得是 main 方法执行完,线程就完了)
设置自定义标志位
通过手动设置一个标志位,来控制线程是否要执行结束。代码如下:
private static boolean isQuit = false;
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (!isQuit) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
isQuit = true;
System.out.println("终止线程");
}
运行结果如下:
因为多个线程共同用一个虚拟地址空间,因此 main 线程修改的 isQuit 和 t 线程判断的 isQuit 是同一个值。
使用 Thread 内置的标志位
- Thread.interrupted() 这是一个静态方法
- Thread.currentThread().isInterrupted() 这是实例方法,其中 currentThread 能够获取到当前线程的实例。一般使用这个。
在主线程中,调用 interrupt 方法,来中断这个线程。代码如下:
public static void main(String[] args) {
Thread t = new Thread(()-> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("收尾工作");
break;
}
}
});
t.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.interrupt();
}
运行结果如下:
调用 interrupt(); 方法,可能慧出现两种情况:
- 如果线程是处在就绪状态,就是设置线程的标志位为 true
- 如果 t 线程处在阻塞状态(sleep 休眠了) 就会触发一个 interruptException,因为 sleep 阻塞了,所以此时设置标志位就不能起到及时唤醒的状态,就会打断 sleep,导致线程从阻塞状态被唤醒,从而继续执行代码,所以我们用 break 来跳出循环。
线程等待 join
多个线程之间,调度顺序不确定。线程之间的执行是按照调度器来安排的,这个过程可能是无序,随机的。线程等待,就是其中一种,控制线程执行顺序的手段,主要是控制线程结束的先后顺序。
调用 join 的时候,哪个线程调用 join 哪个线程就会阻塞等待,等到对应线程的 join 执行完毕为止(对应线程的 run 执行完)。
在主线程当中使用一个等待操作,来等待 t 线程执行结束。调用这个方法的线程,是 main 线程。针对 t 这个线程对象调用的。此时就是让 main 等待 t。调用 join 之后,main 线程就会进入阻塞状态(暂时无法在 CPU 上执行)。
代码执行到 join 这一行,就暂时停下了,不继续往下执行了。等到 t 的 run 方法跑完之后,join 就能继续往下走了,恢复成就绪状态。
但是 join 默认情况下,是死等(不见不散)。所以 join 提供了另外一个版本,可以执行等待时间,最长等多久,等不到就撤了。就是在 join(时间)
代码如下:
public static void main(String[] args) {
Thread t = new Thread(()-> {
for (int i = 0; i < 5; i++) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
try {
t.join(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
运行结果如下:
获取当前线程的引用
通过 currentThread() 的 getName() 方法 ,不过要注意的是,哪个线程调用这个方法,获取到的就是哪个线程的引用。
Thread 自带的 getName
代码如下:
public static void main(String[] args) {
Thread t = new Thread() {
@Override
public void run() {
System.out.println(this.getName());
}
};
t.start();
}
这里事通过 Thread 的方式来创建线程。此时在 run 方法当中,直接通过 this 拿到的就是 Thread 的实例。运行结果如下:
没指定名字,默认是 0。
Runnable 实现
如果是 Runnable 的话,就不能用 this.getName 了,因为 Runnable 是一个单纯的任务,没有 name 属性。会直接抛出受查异常。所以只能用 Thread.currentThread().getName() ,如果是 lambda 表达式,也是这样。代码如下:
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
});
t.start();
}
运行结果如下:
线程休眠 sleep
- 进程是通过 PCB 描述的,通过 双向链表组织的,但这个说法是针对只有一个线程的进程。
- 如果一个进程有多个线程,所以对应的就是一组 PCB 了。
- PCB 上有一个字段 tgroupld 这个 id 就相当于进程的 id,同一个进程当中的若干线程的 tgroupld 是相同的。
流程如下图所示:
进程的状态
就绪和阻塞。但是在 Java 的 Thread 类中,对于线程的状态,又进一步细化了。细化之后更方便查看问题出在哪里。
Java 当中细化的进程状态
- NEW:安排了工作, 还未开始行动。就是把 Thread 对象创建好了,但是还没有调用 start,也就是还没有创建线程。
- TERMINATED:工作完成了.操作系统当中的线程以及执行完毕销毁了,但是 Thread 对象还在。
- RUNNABLE:可工作的. 又可以分成正在工作中和即将开始工作。就是就绪状态,处于这个状态的线程,就是在就绪队列中,随时可以被调度到 CPU 上。如果代码当中没有进行 sleep 和其它可能导致阻塞的操作。代码大概率是处于 RUNNABLE 状态的。
- TIMED_WAITING:代码当中调用了 sleep 就会进入 TIMED_WAITING。或者 join 后面加上 超时时间,也会进入 TIMED_WAITING 状态,就是当前线程在一段时间之内,是阻塞状态。
- BLOCKED:表示当前线程在等待锁,导致了阻塞(阻塞状态之一)加锁的时候用 synchronized 。
- WAITING:当前状态在等待唤醒,导致了阻塞(阻塞状态之一)。
线程运行状态流程: NEW -> start -> RUNNABLE -> run 方法执行完 -> TERMINATED。在 RUNNABLE 的时候可以通过 sleep 来达到 TIMED_WAITING 或者通过 加锁 来达到 BLOCKED,或者通过 wait 来达到 WAITING 效果。
查看线程状态 getState
一些关键线程阻塞,就会出现卡死的情况。分析卡死原因的时候,第一步先看看线程所处的状态,看了状态之后分析程序出现问题的原因。代码如下:
public static void main(String[] args) {
Thread t = new Thread(()-> {
for (int i = 0; i < 5; i++) {
System.out.println("Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
System.out.println(t.getState());
}
运行结果如下:
线程安全
线程安全是线程当中最重要,最复杂的问题。多进程是最基本的处理并发编程的任务。有很多模型:actor csp async+await。操作系统调用线程的时候,是随机的(抢占式执行),因为是抢占式的,所以可能出现 bug 如果调度随机性,引入了 bug,那么就认为代码线程是不安全的。代码示例:
class Counter {
public static int count;
public void increase() {
count++;
}
}
public class Test2 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
在代码中,用 count 作为两个线程自增的变量。运行多次,结果如下:


运行多次之后发现结果总是不能达到 100000,就说明程序有 bug,两个程序是并发执行的,如果两个线程同时自增,就只加了 1。
count++ 其实是 三个 CPU指令:
- 把内存当中的 count 值,加载到 CPU 寄存器当中。
- 把寄存器当中的值 + 1。
- 把寄存器的值写回到 内存的 count 当中。
因为是抢占式执行,就导致两个线程同时执行这三个指令的时候,就充满了随机性。就可能出现一组先后排列的情况,也就是不能都完成加 1 。
就像下面这种情况:
通过 load 把 count 加载到寄存器当中,两个寄存器读到的 count 值都是 1。然后 add 之后的值也都是 1。
也就是最后写会内存的值都是 1。就相当于少加了一次。
或者像下面着这情况:
仍然是少加了一次。
这里加的结果是在 50000 - 100000 之间。因为有一部分是串行的,有一部分是交错的。所以,如果能让 t1 先执行完,然后再让 t2 执行,就可以解决这样的问题了。
通过加锁来保证线程安全
像上面这种情况,就可以通过加锁来解决,我们这里使用 synchronized 来对 count++ 加锁,因为问题是出在 count++ 这里,所以我们对 count++ 加锁就好了。也就是在自增之前先加锁,自增之后解锁。解锁之后再执行另外一个线程。加锁之后,并发程度降低,数据更安全了,但是速度慢了。并发性越高,速度越快,但是可能会出现一些问题,就像这里的 count++ 。实际开发当中,一个线程要做的事很多。可能只有某一个步骤有线程安全,所以只对有线程安全的加锁就好了。代码如下:
class Counter {
public static int count;
synchronized public void increase() {
count++;
}
}
public class Test2 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果如下:
通过加锁之后,就保障了数据的安全。
产生线程不安全的原因
- 线程是抢占式执行,线程间的调度充满随机性,是线程不安全的万恶之源。
- 多个线程对同一个变量进行操作。
- 针对变量的操作不是原子性的,也会导致线程不安全。
- 内存可见性,也会影响到线程安全。例如:针对同一个变量,一个线程进行读操作(循环进行很多次),一个线程进行修改操作(合适的时候执行一次)。读内存比读寄存器慢很多,循环一直去读的话,消耗就会很多,因此,频繁的读内存的值,就会非常低效,而且修改的线程迟迟不修改,读到的值一直是一样的值。所以,读的时候,就可能不从内存读数据了,而是直接从寄存器里面读。如果此时 把值修改了,那么就读不到这个值了。
- 指令重排序:也是编译器优化的一种操作。就是调整代码的执行顺序,执行效果不变,但是效率就提高了。调整的前提也是逻辑不变。代码是单线程的话,一般不会出问题,如果是多线程的话,就可能出现问题,避免问题还是通过 synchronized 加锁来操作。
内存可见性导致的线程不安全
在出现内存可见性问题的时候,当改变值的时候,线程并不会读到值的改变。代码示例:
private static int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while (isQuit == 0) {
}
System.out.println("循环结束,t 线程退出");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值:");
isQuit = scanner.nextInt();
System.out.println("main 线程执行完毕");
}
运行结果如下:
通过图片可以看到,当输入值之后,已经不满足线程执行的条件了,但是线程并没有停止,就是因为内存可见性的原因,导致线程还在运行。如果在线程当中加入 sleep 的话,就不会有这个问题了。
解决内存可见性的方法
- 使用 synchronized 关键字加锁。不光保证原子性,还能保证内存可见性。被 synchronized 包裹起来的代码,编译器就不敢轻易的做出上面优化的那种操作。
- 使用 volatile 关键字,volatile 和原子性无关,但是能保证内存可见性。就会禁止编译器做出优化,使得编译器每次判断的时候,都会重新从内存当中读取 isQuit 的值。
代码如下:
private static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while (isQuit == 0) {
}
System.out.println("循环结束,t 线程退出");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值:");
isQuit = scanner.nextInt();
System.out.println("main 线程执行完毕");
}
运行结果如下:
指令重排序
指令重排序也会影响到线程安全问题,也是编译器优化的一种操作。举例:去超市买东西:

如果按照菜单顺序买菜的话,就会绕很多路,浪费很多时间。如果重新排序之后再买的话,就是下面这种情况:
就会节省很多时间,这就是指令重排序带来的优化。不过在有些时候,写的功能很多的情况下,指令重排序也会导致程序,出现 bug。所以通过 synchronized 来解决这种问题。
synchronized 使用
synchronized 是同步的意思。不同的环境中,有不同的含义:
- 多线程中,线程安全中,同步 指的是“互斥”,一个进行的时候,另外一个就不能进行了。
- 在 IO 或者 网络编程 中,同步 相对的词叫做“异步” 此处的同步和互斥没有任何关系,和线程也没有关系,表示的是消息的发送方如何获取到结果。
直接修饰使用方法
使用 synchronized 直接修饰普通方法。本质是对某个对象进行加锁。在 Java 当中,每个类都是继承自 Object 。每个 new 出来的实例,里面一方面包含了自己安排的属性,一方面包含了“对象头”,对象的一些元数据。此时的锁对象就是 this,如下图所示:
当两个线程同时尝试对同一个对象加锁的时候,才有竞争,如果是两个线程在针对两个不同对象加锁,就没有竞争。
修饰代码块
使用 synchronized 修饰一个代码块。需要显示指定针对哪个对象加锁(Java 中的任意对象都可以作为锁的对象)。代码如下:
public void increase() {
synchronized (this) {
count++;
}
}
修饰一个静态方法
使用 synchronized 修饰一个静态方法。相当于针对当前的类对象加锁,也就是反射。把 synchronized 修饰到 static 方法上:
就相当于是下面这种情况:
就是针对类对象加锁。
监视器锁 monitor lock
synchronized 最原始的意义:
- 互斥
- 刷新内存
- 可重入:同一个线程针对同一个锁,连续加锁两次。如果出现了死锁,就是不可重入。如果不会死锁,就是可重入。
加两层锁
就是外层先加了一次锁,然后里层再对对象加一次锁。代码示例:
synchronized public void increase() {
synchronized (this) {
count++;
}
}
- 外层锁:进入方法,则开始加锁,这次能够加锁成功,因为当前锁没有人占用。
- 里层锁:进入代码块,开始加锁,这次加锁不能加锁成功,因为这个锁被外层占用了,要等到外层锁释放,里层锁才能加锁。
- 外层锁要执行完整个方法,才能释放。但是要想执行完整个方法,就得让里层锁加锁成功继续往下走。所以就变成死锁了。
为了防止出现这种情况,JVM 就实现了可重入锁,就是发生这种操作的的时候,不会死锁。就是可重入锁内部,会记录当前的锁被哪个线程占用,同时也会记录一个加锁次数。线程 a 针对锁第一次加锁的时候,是可以加锁成功的。锁内部就记录了当前的占用着的是 a,加锁次数是 1。后续再 a 对锁进行加锁,此时就不是真加锁,而是单纯的把计数器自增,加锁次数为 2。然后在解锁的时候,先把计数进行 -1,当锁的计数减到 0 的时候,就真的解锁。可重入锁的意义就是:降低了程序员的负担(降低了使用成本,提高了开发效率),但也带来了代价,程序中需要又更高的开销(维护锁属于哪个线程,并且加减计数,降低了运行效率)。
死锁的其它场景
- 一个线程,一把锁。
- 两个线程,两把锁。你要锁,他也要锁,就互相要,然后就死锁了。
- N 个线程,M 把锁。经典问题就是:哲学家就餐问题。
哲学家就餐问题:如下图:
每个哲学家都很固执,在想要吃饭的时候,如果筷子被别人占用,就会死等下去。所以,如果五个人同时拿起左手边的筷子,就陷入死锁了。
每个人都能拿起左手的筷子,每个人都拿不起右手的筷子。
死锁的四个必要条件
- 互斥使用:一个锁被一个锁占用之后,其他线程占用不了(锁的本质,保证原子性)。
- 不可抢占:一个锁被一个线程占用之后,其他线程不能把这个锁给抢走(挖墙脚不行)。
- 请求和保持:当一个线程占据了多把锁之后,除非四显示的释放锁,否则这些锁是在都是被该线程持有的。
- 环路等待:等待关系,成环了:A 等 B,B 等 C,C 又等 A。避免环路等待:约定好,针对多把锁加锁的时候,有固定的顺序就好。所有的线程都遵守同样的规则顺序,就不会出现环路等待。
Java 标准库当中的类
线程安全的部分:
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
- String
线程不安全的部分:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
在多线程当中,线程不安全的类要谨慎使用。
volatile 与 synchronized
volatile 只保证内存可见性,不保证原子性。禁止编译器优化,保证内存可见性。
如果无脑用 synchronized 的话,容易导致线程阻塞,一旦线程阻塞(放弃CPU),下次回到 CPU,这个时间就不可控了,如果调度不回来,自然对任意的任务执行时间就拖慢了。一旦代码当中使用了 synchronized ,这个代码大概率就和 高性能 无缘了。
volatile 就不会引起线程阻塞。
wait 和 notify
wait 和 notify 。就是等待和通知。是处理线程调度随机性的问题的,不喜欢随机性,需要让彼此之间有个固定的顺序。join 也是一种控制顺序的方式,更倾向于控制线程结束。
调用 wait 方法就会陷入阻塞。阻塞到有其他线程通过 notify 来通知。 wait 内部会做三件事:1、先释放锁 2、等待其他线程的通知 3、收到通知之后,重新获取锁,并继续往下执行。因此,想用 wait/notify 就得搭配 synchronized。代码如下:
public static void main1(String[] args) throws InterruptedException {
Object object = new Object();
//wait 哪个对象,就得针对哪个对象加锁。
synchronized (object) {
System.out.println("wait 前");
object.wait();
System.out.println("wait 后");
}
}
运行结果如下:
可以看到在 wait 之后,程序就陷入了 WAITING 状态。如图:
就是由 wait 引起的阻塞。
搭配举例
在第一个线程 wait 之后,就可以通过第二个线程的 notify 来唤醒第一个线程。代码如下:
public static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("wait 之前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 之后");
}
});
t1.start();
Thread.sleep(3000);
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("notify 之前");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
locker.notify();
System.out.println("notify 之后");
}
});
t2.start();
}
然后代码当中就是 wait 三秒之后,进入线程二,然后打印出 notify 之后,再等待 3秒,然后使用 notify 唤醒。运行结果如下:
然后唤醒:
假设有两个线程 线程 t1:a b c d。 线程 t2:e f g h。我们需要让两个线程按照:a->e,b->f,c->g,d->h 顺序执行。就通过 wait 和 notify 如下图:
在执行完 a 之后进入 wait,然后执行完 e 之后,进行 notify,以此类推,就可以实现按顺序执行了。
notifyAll
假如有一个对象 o 有 10 个线程,都调用了 o.wait 此时 10 个线程都是阻塞状态。如果调用了 o.notify 就会把 10 个当中的一个给唤醒(唤醒哪个不确定),使用 notifyAll 就会把所有的 10 个线程都给唤醒。wait 唤醒之后,就会重新尝试获取到锁(这个过程就会发生竞争)。
边栏推荐
- Boutique website navigation theme whole station source code WordPress template adaptive mobile terminal
- One click compilation and deployment of MySQL
- 统计遗传学:第三章,群体遗传
- Pytest basic self-study series (I)
- Emlog user registration plug-in is worth 80 yuan
- tdk-lambda电源主要应用
- Leetcode brush questions: binary tree 05 (flip binary tree)
- 量子力学习题
- 网络 - VXLAN
- 仿《游戏鸟》源码 手游发号评测开服开测合集专区游戏下载网站模板
猜你喜欢

RPC技术

Unity draws the trajectory of pinball and billiards

深入解析结构化异常处理(SEH) - by Matt Pietrek

Wechat brain competition answer applet_ Support the flow main belt with the latest question bank file

毕业设计:设计秒杀电商系统

分布式CAP理论

Ppt tutorial, how to save a presentation as a PDF file in PowerPoint?

Flink学习7:应用程序结构

资深开发人员告诉你,怎样编写出优秀的代码?

ctf-pikachu-XSS
随机推荐
领导:谁再用redis过期监听实现关闭订单,立马滚蛋!
RHCSA 08 - automount配置
Flink学习6:编程模型
What does software testing do? Find defects and improve the quality of software
RHCSA 01 - 创建分区与文件系统
NFT新的契机,多媒体NFT聚合平台OKALEIDO即将上线
leetcode 121 Best Time to Buy and Sell Stock 买卖股票的最佳时机(简单)
分布式系统:what、why、how
Distributed system: what, why, how
【愚公系列】2022年7月 Go教学课程 002-Go语言环境安装
hbuildx中夜神模拟器的配置以及热更新
(指针)编写函数void fun(int x,int *pp,int *n)
Leetcode skimming: binary tree 08 (maximum depth of n-ary tree)
RHCSA 06 - suid, sgid, sticky bit(待补充)
[microservices openfeign] two degradation methods of feign | fallback | fallbackfactory
Emlog用户注册插件 价值80元
疫情远程办公经验分享| 社区征文
leetcode刷题:二叉树06(对称二叉树)
Imitation of "game bird" source code, mobile game issue evaluation, open service, open test collection, game download website template
NFT new opportunity, multimedia NFT aggregation platform okaleido will be launched soon