在Java中,线程的状态可以分为以下几种主要状态:New
(新建状态)、Runnable
(可运行状态)、Blocked
(阻塞状态)、Waiting
(等待状态)、Timed Waiting
(定时等待状态)、Terminated
(终止状态)。
New
(初始状态)New
(新建状态):线程处于新建状态,已经创建了线程对象但尚未调用其start()
方法。在这个状态下,线程对象已经被创建,但尚未分配系统资源。
1 | public static void main(String[] args) { |
Terminated
(终止状态)Terminated
(终止状态):线程进入终止状态表示它已经执行完成或因异常而终止。一旦线程的run()
方法完成,它就会进入终止状态。一旦线程终止,它将不再处于任何其他状态。
1 | public static void main(String[] args) throws InterruptedException { |
Runnable
(可运行状态)Runnable
(就绪状态):线程在这个状态下已经被启动,可以运行。它可能正在执行,也可能处于等待CPU资源的状态,或者是在等待某个特定的条件(如等待I/O操作完成)。就绪状态,可以理解为两种情况:1.线程正在 CPU 上运行。 2. 线程在这里排队,随时都能去 cpu 上执行。
1 | public static void main(String[] args) throws InterruptedException { |
Timed_Waiting
(定时等待状态):线程进入定时等待状态是因为调用了具有超时参数的等待方法,如sleep()
或join()
。它会在指定的时间间隔内等待,或者直到被唤醒或中断。
Blocked
(阻塞状态):线程进入阻塞状态通常是因为它在等待某个条件满足而无法继续执行,例如等待某个锁。一旦条件满足,线程将进入Runnable
状态。
Waiting
(等待状态):线程进入等待状态是因为调用了wait()
方法,或者类似的等待方法,它会一直等待直到被其他线程唤醒或中断。
总结:Blocked
是因为锁产生了阻塞,Waiting
是因为wait()
方法产生的阻塞,Timed_Waiting
是因为sleep()
或join()
产生的阻塞。
1 | public static void main(String[] args) throws InterruptedException { |
在开始线程安全之前,先通过一个小Demo来感受一下线程安全。
1 | class Counter{ |
这个例子中,两个线程针对同一个变量,进行循环自增,各自自增5000次。按照正常情况,预计结果应该是10000。
但是,实际结果却是7794,并且同样的代码每次运行结果居然还不一样!
正常来看,我们的代码肯定没有问题,但是还是出现了这个bug,这个 bug 其实就是线程安全问题。
线程不安全的根本原因是:多个线程之间的调度是“随机的”,操作系统使用的是“抢占式”执行的策略来调度线程。
根据这个根本原因,可以衍生出一些其他原因:
知道了线程不安全的原因后,再看一下上面的Demo,分析为什么会出现线程不安全的问题。
上面的线程不安全问题的bug,主要的原因就是count++
这个代码出现了问题。我们拆解一下count++
这个操作。
这个count++
操作其实本质上是三个步骤:
如果上述的操作,出现在单线程中其实是不会出现任何问题的,但是出现在多线程中就会出现问题。因为是两个线程并发执行,线程的调度是随机的,抢占式的执行。
这个Demo中,除了根本原因外,还出现了上面问题中的两个问题,“多个线程同时修改同一变量” 、“进行的修改不是原子的。”
多个线程修改同一个变量这个是我们的需求,所以我们没办法去改变他,所以,我们只能去解决“进行的修改不是原子的”这个问题。
如何修改为原子操作?加锁!所谓加锁,就是把把一组操作,打包成为一个原子的操作。Java 中引入了一个synchronized
关键字进行加锁。这个关键字在后面详细解释,先使用这个给count++操作进行加锁。
1 | class Counter{ |
使用synchronized
给方法加锁,进入方法就自动加锁(lock),出了方法就自动解锁(unlock)。
当这个这个方法加锁后,这个方法就变成如下的样子:
当 t1 加锁后,t2 也尝试加锁,t2 就会阻塞等待。等待到 t1 释放锁后才能加锁成功。直到 t1 完成了 count++
,t2 才能真正进行 count++
。把穿插执行变成了串行执行。
synchronized
关键字synchronized
会起到互斥效果,某个线程执行到某个对象的 synchronized
中时,其他线程如果也执行到同一个对象 synchronized
就会阻塞等待。
可以粗略理解为,每个对象在内存中存储的时候,都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”)。
synchronized
的工作过程:
所以 synchronized 也能保证内存可见性。
synchronized
同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
什么是 “把自己锁死” ?一个线程没有释放锁, 然后又尝试再次加锁。
1 | // 第一次加锁, 加锁成功 |
按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待。直到第一次的锁被释放,才能获取到第二个锁。但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作。这时候就会出现死锁,这种锁也叫不可重入锁。Java 中的 synchronized
是 可重入锁,因此没有上面的问题。
下面有一些使用示例,但是大家只需要牢记一条规则:
修饰方法: 这种方式是修饰整个方法,即使方法中没有同步代码块,也会锁定这个方法,这种方式适用于整个方法需要同步的情况。
1 | public synchronized void method() { |
修饰代码块: 这种方式是将同步代码块包在synchronized
括号内,只有在执行到synchronized
代码块时才会锁定,这种方式适用于只需要同步执行部分代码的情况。
1 | public void method() { |
修饰静态方法: 和修饰方法类似,这种方式是锁定整个静态方法,适用于整个静态方法需要同步的情况。
1 | public synchronized static void method() { |
修饰类: 这种方式是锁定整个类,即使不同实例中的线程也会被锁定,适用于整个类需要同步的情况。
1 | public void method() { |
volatile
关键字1 | import java.util.Scanner; |
这段代码的理想状态: t1始终在进行while循环,t2则是要让用户通过控制台输入一个整数,作为isQuit的值。当用户输入的仍然是0的时候,t1线程继续执行。如果用户输入的非0,t1线程就应该循环结束。
但是,实际上是当输入非 0 值的时候,已经输入 isQuit
的值的时候,t1 线程还在继续执行,不符合实际的预期。
导致这个的原因是因为程序在编译运行的时候,Java 编译器和 jvm 可能会对代码进行一些优化。当你的代码实际执行的时候,编译器 jvm 就可能把你的代码给改了,在保持原有逻辑不变的情况下,提高代码的效率。
编译器优化本质上是靠代码智能的对你的代码进行分析判断,这个过程中大部分是 ok 的,能保证代码逻辑不变,但是如果遇见多线程了,此时优化就有可能出现差错。使程序原有的逻辑发生改变。
编译器/jvm 发现,在这个逻辑中,代码要反复快速的读取同一个内存的值,并且这个值每次读取的还是一样。此时,编译器做出了一个大胆的决定,直接把 load 操作给优化掉了,只是第一次执行 load 后续不再执行 load 操作,直接拿寄存器中的数据进行比较了。但是,编译器没有想到,程序员在另外一个线程中修改了 isQuit
的值,因此就出现了误判。
volatile
用法volatile
本质上是保证变量的内存可见性(禁止该变量的读操作被优化到读寄存器中),不是原子性。
代码在写入 volatile
修饰的变量的时候:
volatile
变量副本的值代码在读取 volatile
修饰的变量的时候:
volatile
变量的最新值到线程的工作内存中volatile
变量的副本为了解决上面的问题,我们只需要用volatile
来修饰这个变量后,编译器就会明白,这个变量是易变的,编译器会禁止上述优化。
1 | public class Demo { |
wait
和 notify
由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序,wait
和 notify
就是解决这个问题的。
wait
方法wait
需要做的事情:
wait
要释放当前的锁,那前提就是他必须要上锁。所以,wait
要搭配 synchronized
来使用,脱离 synchronized
使用 wait
会直接抛出异常。1 | public class Demo4 { |
这里的 wait
会阻塞到其他线程 notify
为止。其中最典型的一个场景就是,能够有效的避免线程饿死。
notify
方法notify
方法是唤醒等待的线程。
notify()
也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify
,并使它们重新获取该对象的对象锁。 wait
状态的线程。(并没有 “先来后到”)notify()
方法后,当前线程不会马上释放该对象锁,要等到执行notify()
方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。1 | package thread; |
注意事项:
notify
能够顺利唤醒wait
,就需要确保wait和notify都是使用同一个对象调用的。wait
和notify
都需要放到synchronized
之内,虽然notify
不涉及解锁操作,但是Java也强制要求notify
放到synchronized
中notify
的时候,另外一个线程没有处于wait
状态,此时的notify
相当于空打一炮,没有任何副作用。wait
和 sleep
的对比其实理论上 wait
和 sleep
完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
硬说区别的话,就是如下:
wait
需要搭配 synchronized
使用.sleep
不需要wait
是 Object
的方法,sleep
是 Thread
的静态方法