Java 多线程
1. 基本概念
1.1.程序、进程、线程
- 程序(program)
- 程序是为完成特定任务,使用某种语言编写的一组指令的集合。即指一段静态的代码(还没有运行起来),静态对象。
- 进程(process)
- 进程是程序的一次执行过程,也就是说程序运行起来了,加载到内存中,并占用了CPU的资源。这是一个动态的过程:有自身的产生、存在和消亡的过程,这也就是一个进程的生命周期。
- 进程是系统资源分配的单位,系统在运行时会为每一个进程分配不同的内存区域。
- 线程(thread)
- 线程可进一步细化线程,是一个程序内部的执行路径。
- 若一个进程同一时间并行执行了多个线程,那么这个进程就是支持多线程的。
- 线程是CPU调度和执行的单位,每一个线程都拥有独立的运行栈和程序计数器(pc),线程切换的开销小。
- 一个进程中,所有的线程共享相同的内存单元/内存地址空间,它们从同一堆中分配对象,可以访问相同的变量和对象。这就使得线程之间的通信更加便捷、高效。但多个线程操作共享的系统资源也带来了一些潜在的安全隐患,例如:数据竞争和死锁等问题。
- 配合JVM内存结构了解
class文件会通过类加载器加载到内存空间。
其中内存区域中的每一个线程都会有一个独立的虚拟机栈和程序计数器。
每一个进程都会有一个方法区和堆,多个线程共享同一个进程下的方法区和堆。
多线程与多进程的比较:
- 创建线程的开销通常小于创建进程的开销,因为线程共享了进程的资源。
- 线程之间的通信相对容易,因为它们共享同一进程的地址空间。
- 多进程的稳定性高于多线程,因为一个进程的崩溃通常不会影响其他进程,但线程的崩溃可能导致整个程序的崩溃。
1.2.并行与并发
- 并行:多个CPU同时执行多个任务。eg:多个人做不同的事。
- 并发:一个CPU(采用时间片)同时执行多个任务。eg:一个人做多个事。
1.3.CPU单核和多核的理解
- 单核CPU在同一时间只能执行一个线程的任务,同时间段内有多个线程需要CPU去运行时,CPU也只能交替地执行这些线程中的一个线程,并不能真正实现并行执行,但由于CPU的执行速度非常快,多个线程之间的切换可能会发生得非常快,给人的感觉就像是同时运行一样,造成单核的CPU可以实现多线程的假象。
- 多核CPU则可以同时执行多个线程的任务,每个核心都能独立地执行线程,从而更好地发挥多线程的效率。在多核CPU上,可以实现真正的并行执行,每个核心都可以独立地执行一个线程的任务,提高了系统的整体性能。
对于Java应用程序java.exe来讲,至少会存在三个线程:main()主线程、gcc()垃圾回收线程、异常处理线程【如果发生异常时会影响主线程】。
1.4.用户线程与守护线程
- 用户线程:用户自定义创建的进程,它们由用户代码启动并执行。JVM会在终止之前等待任何用户进程完成其任务。【主线程停止,用户线程并不会直接停止,会继续执行,直到完成其任务或显式被中止】
- 守护线程:守护线程是用来服务用户线程的,通过在start()方法前调用thread.setDaemon(true)可以把一个用户线程变成一个守护线程。守护线程通常用于执行一些不需要在程序结束前完全执行的后台任务。【当所有的用户线程都执行完毕并且主线程退出时,JVM 会自动停止所有的守护线程,即使它们还没有执行完任务】 eg:Java的gc()垃圾回收线程就是一个守护线程。
1.5.多线程的优点
- 提高应用程序的响应。对图像化界面更有意义,可以增强用户体验。
- 提高计算机CPU的利用率。
- 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。
1.6.应用场景
- 程序需要同时执行两个或多个任务
- IO密集型程序:1.解决超时 2.防止阻塞
- 需要一些后台运行的程序时
- 迅雷多线程下载、数据库连接池、分批发送短信等
2. 线程的生命周期
2.1.线程的状态图
2.2.线程状态详细说明
新建(New)
- 使用new创建一个线程(Thread)之后,但还没有调用start()方法时。
- 该状态下线程对象已经被创建,但还未开始执行。
可运行(Runnable)
- 当线程调用start()方法之后,线程就处于可运行状态,会创建方法调用栈和程序计数器,这个状态还可继续拆分成2个状态:
- 就绪(Ready):处于线程就绪队列、等待分配CPU时间片
- 运行(Running):线程获得CPU时间片,开始执行run()方法,可能变为阻塞状态、就绪状态、死亡状态
阻塞(Blocked)
- 纯粹的阻塞状态通常是指线程被动地暂时停止执行,直到某些条件满足为止,而不涉及等待一段时间或等待其他线程的唤醒。存在以下两种情况:
- 等待获得锁
- 调用一个阻塞式IO
- 阻塞状态线程无法直接转为运行状态,需要先转为就绪状态
等待(Waiting)
- 线程进入等待状态通常是因为需要等待其他线程满足某些条件或执行某些操作,等待的时间是不确定的。
- 进入等待状态的方法:
Object.wait()方法
:- 只能在synchronized代码块或synchronized方法中调用。
- 调用前必须获得对象的锁,否则会抛出IllegalMonitorStateException异常。
- wait()方法调用时会释放持有的对象锁。
- 调用wait()方法,需要等待
notify()
或notifyAll()
方法唤醒。
Thread.join()方法
:- 可以在任何时候调用,通常用于等待指定的线程【即调用join()方法的线程】执行完毕。
- 调用前不需要获得任何锁,但会等待目标线程执行完毕后返回。
- 若调用前持有对象锁,并不会释放持有的对象锁。
LockSupport.park()/LockSupport.park(Thread thread)方法
:- 可以在任何时候调用,通常和LockSupport类一起使用。
- 无参时当前线程进入等待状态,有参时指定线程进入等待状态。
- 调用前不需要获得任何锁。
- 若调用前持有对象锁,并不会释放持有的对象锁。
- 调用LockSupport.park()方法,需要等待
LockSupport.unpark(Thread thread)
方法唤醒,这种方式唤醒打断标记为false。 - 也可以使用thread.interrupt()进行唤醒,但是这样子打断标记会标记为true。
- 如果打断标记已经是 true, 则 park 会失效
- 如果在 park 之前调用了 unpark 方法,那么 park 将不会使线程进入等待状态,而是继续执行,也就是说 unpark 方法可以提前调用
public static void main(String[] args) throws Exception { Thread t1 = new Thread(() -> { System.out.println("park..."); LockSupport.park(); System.out.println("unpark..."); System.out.println("打断状态:" + Thread.currentThread().isInterrupted());//打断状态:true LockSupport.park(); System.out.println("unpark..."); }, "t1"); t1.start(); Thread.sleep(2000); t1.interrupt();////打断状态:true //LockSupport.unpark(t1);//打断状态:false } /**输出: * park... * unpark... * 打断状态:true * unpark... */
超时等待(TIME_WAITING)
- 线程进入超时等待状态是因为需要等待一段时间后再继续执行,等待的时间是确定的。
- 进入超时等待状态的方法:
Thread.sleep(long millis)
:- 让当前线程睡眠指定的毫秒数。
- 调用该方法后,当前线程会暂停执行指定的毫秒数,然后继续执行。
- 这个方法不会释放持有的锁。
Object.wait(long timeout)
:- 使当前线程等待指定的毫秒数,或者直到其他线程调用了相同对象的
notify()
或notifyAll()
方法。 - 当前线程会进入超时等待状态,直到等待时间超时,或者其他线程调用了相同对象的
notify()
或notifyAll()
方法。 - 调用wait()方法会释放持有的对象锁,直到被唤醒后重新获取锁才会继续执行。
- 使当前线程等待指定的毫秒数,或者直到其他线程调用了相同对象的
Thread.join(long millis)
:- 等待调用join()方法的线程执行完毕,或者直到指定的毫秒数之后。【若参数为0,则表示永远等待】
- 如果调用join()方法的线程在指定的时间内执行完毕,则当前线程会恢复执行。
- 这个方法不会释放持有的锁。
LockSupport.parkNanos(long nanos)
:- 使当前线程进入超时等待状态,直到指定的纳秒数后或者被中断。
- 这个方法不会释放持有的锁。
LockSupport.parkUntil(long deadline)
:- 参数 deadline 是一个表示时间戳的长整型数值,单位是毫秒。
- 使当前线程进入超时等待状态,直到指定的时间点后或者被中断。
- 这个方法不会释放持有的锁。
死亡(Dead)
- 当一个线程已经执行完run()/call()方法中的所有操作时,该线程就处于死亡状态。
- 线程抛出一个未捕获的Exception或Error,线程就会死亡。
- 一旦线程处于死亡状态,线程对象就会被垃圾回收器移除。
2.3.状态之间的转换及调用方法:
- New -> Runnable: 调用start()方法启动线程。
- Runnable -> Running: 线程被操作系统调度并获取CPU时间片。
- Running -> Blocked: 线程等待获取锁或执行阻塞式IO操作。
- Running -> Waiting: 调用wait()、join()、park()等方法,线程进入等待状态。
- Running -> Timed_Waiting: 调用Thread.sleep(long)、wait(timeout)等方法,线程进入超时等待状态。
- Blocked -> Runnable: 获取到锁或IO操作完成,线程重新进入可运行状态。
- Waiting -> Runnable: 其他线程调用notify()、notifyAll()、interrupt()方法,或者等待时间到达,线程重新进入可运行状态。
- Timed_Waiting -> Runnable: 等待时间到达,线程重新进入可运行状态。
- Running/Blocked/Waiting/Timed Waiting -> Dead: 线程执行完run()方法中的所有代码,或者抛出未捕获的异常,线程进入死亡状态。
2.4.几个方法的比较
- Thread.sleep(long millis):一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
- Thread.yield():一定是当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让CPU再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。
- thread.join()/thread.join(long millis):当前线程里调用其它线程T的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程T执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。
- obj.wait():当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
- obj.notify():唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
- LockSupport.park()/LockSupport.parkNanos(long nanos),LockSupport.parkUntil(long deadlines):当前线程进入WAITING/TIMED_WAITING状态。对比wait方法,不需要获得锁就可以让线程进入WAITING/TIMED_WAITING状态,需要通过LockSupport.unpark(Thread thread)唤醒。
进入等待状态的几个方法比较:
3.Thread类
3.1.基本概念
- Thread类是Java中用于表示线程的类。通过Thread类,可以创建新的线程、启动线程的执行、控制线程的状态和执行等。除了线程对象本身的操作,Thread类还提供了对线程组(ThreadGroup)的支持,以便更好地管理和组织线程。
什么是线程组(ThreadGroup):
- 线程组是线程的集合,它用于对线程进行逻辑上的分组。线程组可以包含其他线程组,形成一棵树状结构。每个线程组可以有一个父线程组,除了顶级线程组外,其他线程组必须有一个明确定义的父线程组。
线程组的作用:
- 组织管理:通过线程可以更好地组织和管理多个线程,便于对线程进行批量操作和控制
- 权限控制:线程组可以应用安全策略和权限控制,限制线程组中线程的行为
- 资源分配:线程组可以分配资源,例如线程组中的线程可以共享资源,例如文件、数据库连接等
- 异常处理:线程组可以统一处理线程中抛出的未捕获异常,提高异常处理的效率和统一性
线程组的父子关系:
- 父子关系:每个线程组可以有一个父线程组,除了顶级线程组外,其他线程组必须有一个明确定义的父线程组。这种关系通过ThreadGroup类来实现,构成了线程组的树形结构
- 组织结构:线程组可以包含其他线程组,形成多层次的组织结构,便于对线程进行层次化管理
父线程组的作用:
- 创建与销毁子线程组:父线程组可以创建和销毁子线程组,从而实现对线程组的管理
- 管理权限:父线程组可以授予或限制子线程组的权限,例如访问特定的资源或执行特定的操纵
- 监控和控制:父线程组可以监控和控制子线程组的状态和行为,例如暂停、恢复或终止子线程组的所有线程
子线程组的作用:
- 资源隔离:子线程组可以限制父线程组的访问权限,确保父线程组无法访问子线程组的资源
- 继承行为:子线程组可以继承父线程组的一些属性:例如优先级、守护状态等,这些使得子线程组可以继承父线程组的行为,并根据需要进行调整
3.2.Thread的构造方法
Thread()
:创建一个默认设置的线程对象实例,默认线程名为Thread-0,默认优先级为5,默认状态为NEW。Thread(String name)
:创建一个线程对象,该线程对象的线程名为name,默认优先级为5,默认状态为NEW。Thread(Runnable target)
:创建一个线程对象,该线程对象通过指定的Runnable对象来执行。Thread(Runnable target, String name)
:创建一个线程对象,该线程对象通过指定的Runnable对象来执行,并且指定线程名为name。Thread(ThreadGroup group,String name)
:创建一个指定的线程组group,并且指定线程名为name。Thread(ThreadGroup group, Runnable target)
:创建一个指定的线程组group,该线程对象通过指定的Runnable对象来执行。Thread(ThreadGroup group, Runnable target, String name)
:创建一个指定的线程组group,该线程对象通过指定的Runnable对象来执行,并且指定线程名为name。Thread(ThreadGroup group, Runnable target, String name, long stackSize)
:创建一个指定的线程组group,该线程对象通过指定的Runnable对象来执行,并且指定线程名为name,并且指定线程的堆栈大小为stackSize。【如果不想指定堆栈大小,可以传递0,由虚拟机自行决定】
说明:关于线程组(ThreadGroup),一个线程组代表一组线程。此外,一个线程组还可以包括其他线程组。线程组形成一棵树,其中除了初始线程组之外的每一个线程组都有一个父级。允许线程直接访问有关其自己的线程组的信息,但不能直接访问有关其线程组的父级线程组或其他线程组的信息。
3.3.Thread的常用方法
方法 | 描述 |
---|---|
String getName() |
返回线程的名称。 |
void setName(String name) |
设置线程的名称。 |
static Thread currentThread() |
返回当前正在执行的线程对象。 |
public final void start() |
启动线程,使其进入就绪状态并开始执行run()方法。如果线程已经启动,再次调用start()方法会抛出IllegalThreadStateException异常。 |
public final void run() |
线程的执行体,包含了线程需要执行的任务逻【直接调用run()方法,并不创建一个新线程,只是在当前线程中执行 run() 方法的内容】 |
static void sleep(long millis) |
使当前线程暂停执行指定的毫秒数。调用sleep()方法会使当前线程进入超时等待状态,不会释放持有的锁。如果在sleep期间被其他线程中断,则会抛出InterruptedException异常,并且清除当前线程的中断状态。 |
public static boolean interrupt() |
中断线程。调用该方法会将线程的中断状态设置为true,如果当前线程正在sleep、wait、join等方法中阻塞,则会立即抛出InterruptedException异常并清除中断状态。 |
public boolean isInterrupt() |
中断线程。调用该方法会将线程的中断状态设置为true,如果当前线程正在sleep、wait、join等方法中阻塞,则会立即抛出InterruptedException异常但不会清除中断状态。 |
void setPriority(int newPriority) |
设置线程的优先级。优先级范围是1~10,其中1为最低优先级,10为最高优先级,默认优先级为5。Java线程优先级的设置只是一个建议,实际的调度由底层操作系统决定,不同操作系统对线程优先级的支持程度不同。 |
final int getPriority() |
返回线程的优先级。 |
final void setDaemon(boolean on) |
设置线程是否为守护线程。如果将线程设为守护线程,当所有的非守护线程结束时,守护线程会被自动终止。默认情况下,用户线程是非守护线程,而守护线程是为了辅助用户线程的运行而存在的。 |
public final boolean isAlive() |
判断线程是否处于活动状态。如果线程已经启动且尚未终止,则返回true;否则返回false。 |
public static void yield() |
提示线程调度器当前线程愿意放弃当前CPU资源。调用yield()方法会让出当前线程的CPU时间片,使得其他具有相同或更高优先级的线程有机会执行,但是并不能保证一定会立即让出CPU,实际中使用yield()方法的效果可能受到操作系统和JVM的调度策略影响。 |
public static void join() |
等待调用join()方法的线程执行完毕。调用join()方法会使当前线程进入等待状态,直到调用join()方法的线程执行完毕,当前线程才会继续执行。如果调用join()方法的线程被中断,则会抛出InterruptedException异常,并清除当前线程的中断状态。 |
普及
Java中线程采用内核线程模型来实现用户程序中的线程,因此一些常用方法依托于虚拟机原生实现,统称Native方法。
Native方法是指在Java中声明但实现是由其他语言(如C、C++)编写的方法。这些方法的实现由本地代码提供,通常是为了与底层系统交互或使用底层系统资源。在Java中,可以使用native
关键字声明一个方法为本地方法,然后在另外的本地语言中实现它。Native方法通常用于与操作系统、硬件或其他非Java程序进行交互,或者执行一些Java无法直接完成的底层操作。
yield()
- yiedld方法是一个Native方法,由C++底层进行关于操作系统层面的逻辑处理。yield的字面意思是退让。调用该方法会向调度程序提示当前线程愿意放弃其当前对处理器的使用,但调度程序可以随意忽略此提示。【无法保证yield达到让步目的】
- yield是一种启发式尝试,使用它可以改善线程之间的相对进展,否则会过度使用CPU。在使用yield方法时通常有以下两种使用场景:
- yield的使用应于详细的分析和基准测试相结合,以确保实际上具有预期的效果,但很少使用这种方法。对于调试或测试目的可能有用,它可能有助于重现由于竞争条件导致的问题错误。
- 在设计并发控制结构(例如 java.util.concurrent.locks包中的结构)时,它也可能会有所帮助。
public class TestYield {
public static void main(String[] args) {
MyThread thread1 = new MyThread("thread-1");
MyThread thread2 = new MyThread("thread-2");
thread1.start();
thread2.start();
}
private static class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
if (i % 2 == 0) {
Thread.yield(); // 实际中无法保证yield()达到让步目的
System.out.println(getName() + ":" + i);
}
}
}
}
}
join()
- join方法让一个线程加入到另一个线程之前执行,在此线程执行期间,其他线程进入等待状态,当然也可以指定join方法的参数(指定执行等待的超时时间),最多等待几毫秒让该线程终止,若参数为0,意味着永远等待。
- 此实现使用以this.isAlive为条件的this.wait调用循环,当线程终止,将调用this.notifyAll方法。建议应用程序不要在Thread实例上使用wait、notify或notifyAll方法。如果任何线程中断了当前线程,则会抛出InterruptedException异常并清除当前线程的中断状态。
public class TestJoin {
public static void main(String[] args) throws InterruptedException {
MyThread thread1 = new MyThread("thread-1");
MyThread thread2 = new MyThread("thread-2");
thread1.start();
thread1.join();// 阻塞当前线程main,直到thread1执行完
thread2.start();
}
private static class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
System.out.println(getName());
}
}
}
}
sleep()
- 当调用线程的sleep方法时,使当前执行的线程休眠(暂时停止执行)指定的毫秒数,取决于系统计时器和调度程序的精度和准确性。如果任何线程中断了当前线程,会抛出InterruptedException异常并清除当前线程的中断状态。
interrupt()
- 使用interrupt()方法来中断线程,除非当前线程正在中断自己,否则会调用该线程的checkAccess方法,这个方法可能会抛出SecurityException异常。主要有以下几种场景:
- 如果一个线程被Object类的wait、Thread类的join、sleep、yield方法调用时,如果当前线程被中断,那么就会抛出InterruptedException异常。
- 如果该线程在InterruptibleChannel的IO操作中被中断,则通道关闭,线程的中断状态将被设置,线程抛出java.nio.channels.ClosedByInterruptException异常。
- 如果该线程在java.nio.channels.Selector的select操作中被中断,则该线程的中断状态将被设置,并且它将立即从选择操作中返回,可能带有非零值,就像调用了选择器的唤醒方法一样。如果前面的条件都不成立,则将设置该线程的中断状态。
import java.lang.annotation.Native;
public class TestInterrupt {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thread");
thread.start();
thread.interrupt();
}
}
/*输出结果:
java.lang.InterruptedException: sleep interrupted
at java.base/java.lang.Thread.sleep0(Native Method)
at java.base/java.lang.Thread.sleep(Thread.java:509)
at TestInterrupt.lambda$main$0(TestInterrupt.java:6)
at java.base/java.lang.Thread.run(Thread.java:1583)*/
3.4.优先级
- Java中创建的线程,每个线程都有一个优先级,具有较高优先级的线程优先于具有较低优先级的线程执行。
但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台。 - 当在某个线程中运行的代码创建一个新的Thread对象时,新线程的优先级最初设置为等于创建线程的优先级。当然,也可以通过调用新线程的setPriority()方法来改变优先级。
- Java线程的优先级是一个整数,其取值范围是 1 (Thread.MIN_PRIORITY ) - 10 (Thread.MAX_PRIORITY )。
- Thread类中定义了以下三个默认优先级:
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
【创建线程的默认优先级】public final static int MAX_PRIORITY = 10;
public class Main {
public static void main(String[] args) {
MyThread2 t2 = new MyThread2();
t2.start(); // 输出:MyThread2 run priority=5【没有设置优先级默认是5】
MyThread1 t1 = new MyThread1();
t1.setPriority(6);
t1.start(); // 输出: MyThread1 run priority=6
// MyThread2 run priority=6【这里的MyThread2在MyThread1的线程里面创建,没有调用方法默认继承父线程的优先级】
}
}
class MyThread1 extends Thread{
@Override
public void run() {
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("MyThread1 run priority="+this.getPriority());
MyThread2 thread2=new MyThread2();
thread2.start();
}
}
class MyThread2 extends Thread{
@Override
public void run() {
System.out.println("MyThread2 run priority="+this.getPriority());
}
}
4.线程的调度
4.1.CPU的调度策略
线程无论基于何种模型创建,都有其调度策略,线程的调度指的是操作系统为线程分配使用权的过程。通常调度方式包含两种:
- 协同式调度(Cooperative Threads Scheduling):
- 使用协同式调度方式的线程调度由其本身来控制,线程在自身工作执行完成后,主动通知系统切换到另一个线程,这种方式实现简单,便于控制。但是过度依赖线程本身来控制调度,如果某个线程执行任务的程序存在问题就会一直阻塞,导致其他线程无法正常执行。
- 抢占式调度(Preemptive Threads Scheduling):
- 使用抢占式调度方式的多线程系统,线程的调度由系统分配执行时间,线程的切换由系统决定。在这种调度方式下,线程的执行时间可控,不会因为单个线程问题导致应用程序阻塞。
什么是时间片:
时间片(Time Slice)是指操作系统中用于调度进程或线程的一段固定长度的时间。在抢占式调度中,每个进程或线程被分配一个时间片,该时间片决定了它能够连续执行的最大时间。当一个进程或线程的时间片用完后,操作系统会中断其执行,并将CPU资源分配给下一个就绪状态的进程或线程。时间片的长度通常是固定的,但在某些调度算法中也可以是可变的。时间片的大小会影响到系统的响应时间、吞吐量和公平性。较短的时间片能够提高系统的响应速度,但会增加上下文切换的开销;而较长的时间片能够减少上下文切换的频率,但可能导致某些进程或线程长时间占用CPU资源,影响其他任务的响应速度。
4.2.Java的调度算法
- 同优先级线程组成FIFO队列(先来先服务),使用时间片策略。
- 堆高优先级,使用优先级抢占式策略。
- 线程优先级等级 1 (Thread.MIN_PRIORITY ) - 10 (Thread.MAX_PRIORITY ),一共10档,默认优先级为5(NORM_PRIORITY)。
- 获取和设置当前线程的优先级
getPriority()
:获取当前线程的优先级。setPriority(int p)
:设置当前线程的优先级。
说明: 高优先级的线程要抢占低优先级的线程CPU的执行权。但这只是从概率上来讲,高优先级的线程的执行概率高,但是并不是绝对的。并不意味着只有高优先级的线程执行完成后,低优先级的线程才可以执行。
public class Main {
public static void main(String[] args) {
MyThread2 t2 = new MyThread2();
t2.setPriority(7);
t2.start();
MyThread1 t1 = new MyThread1();
t1.setPriority(6);
t1.start();
/*输出:
MyThread2 run priority=7
MyThread2 run priority=7
MyThread1 run priority=6
MyThread1 run priority=6
MyThread1 run priority=6
MyThread2 run priority=7
高优先级的也有可能比低优先级的晚结束*/
}
}
class MyThread1 extends Thread{
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println("MyThread1 run priority="+this.getPriority());
}
}
}
class MyThread2 extends Thread{
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println("MyThread2 run priority="+this.getPriority());
}
}
}
5.多线程的实现原理和创建
5.1.多线程的实现原理
- Java语言的JVM允许运行多个线程,多线程可以通过Java中的java.lang.Thread类来实现。
- Thread类的特性
- 每一个线程都是通过某个特定的Tread对象的run()方法来完成操作的,经常把run()方法的主体称为线程体。
- 通过Thread方法的start()方法来启动一个线程,而非直接调用run()方法。【start()底层会调用start0()来创建一个新的线程,调用run()方法,而直接调用 run() 方法只是在当前线程中执行 run() 方法的内容,并不会创建新的线程】
5.2.多线程的创建
5.2.1.继承Thread类[拓展性较差]
- 创建一个继承Thread类的子类
- 重写Thread类的run()方法,将此线程执行的操作声明在run()中
- 创建Thread类的子类对象
- 通过此对象调用start()方法来启动一个线程。
public class Main {
public static void main(String[] args){
// 创建一个Thread类的子类对象
MyThread thread1 = new MyThread();
// 通过调用此对象的start()启动一个线程
thread1.start();
// 注意:已启动过一次的线程无法再次启动【在Java中,一个线程对象只能被启用一次】
// 再创建一个线程
MyThread thread2 = new MyThread();
thread2.start();
// 另一种调用方法,此方法并没有给对象命名【链式编程】
new MyThread().start();
System.out.println("主线程");
}
}
class MyThread extends Thread{
@Override
// 线程体,启动线程时会运行run()方法中的代码
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + ": " + i);
}
}
}
/*输出
主线程
Thread-1: 0
Thread-1: 1
Thread-2: 0
Thread-2: 1
Thread-0: 0
Thread-0: 1
Thread-0: 2
Thread-1: 2
Thread-2: 2
*/
创建Thread匿名子类的方式:
public class AnonymousSubClass {
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (i % 2 == 0) System.out.println(Thread.currentThread().getName() + ": " + i);
}
}
}.start();
}
}
5.2.2.实现Runnable接口[拓展性强]
- 创建一个实现了Runnable接口的类
- 实现类中重写Runnable接口的run()方法,将此线程执行的操作声明在run()中
- 创建实现类的对象
- 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
- 通过Thread类的对象调用start()方法来启动线程
public class Main {
public static void main(String[] args){
// 创建实现类的对象
RunnableThread runnableThread = new RunnableThread();
// 创建Thread类的对象,并将实现类的对象当作参数传入构造器
Thread thread01 = new Thread(runnableThread);
// 给该线程命名
thread01.setName("Thread 01");
// 使用Thread类的对象去调用Thread类中的start()方法:1.启动线程 2.Thread中的run()调用了Runnable中的run()
thread01.start();
// 再创建一个线程时,可以直接再new一个Thread类即可,不需要再new实现类
/*【但这种方式会共享同一个 runnableThread 对象中的状态,包括非 static 修饰的成员变量。
因为 thread01 和 thread02 实际上都是使用同一个 runnableThread 对象作为参数传递给了 Thread 类的构造器】*/
Thread thread02 = new Thread(runnableThread);
thread02.setName("Thread 02");
thread02.start();
}
}
class RunnableThread implements Runnable{
//RunnableThread 实现Runnable接口中的run()抽象方法
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (i % 2 == 0) System.out.println(Thread.currentThread().getName() + ": " + i);
}
}
}
继承Thread、实现Runnable的区别
- Java只允许单继承,但可以实现多个接口。因此实现接口的方式没有类的单继承性的局限,用实现Runnable接口的方式来完成多线程更加实用。
- 实现Runnable接口的方式天然具有共享数据的特性(不需要static修饰)。因为继承Thread的实现方式,需要创建多个子类的对象来进行多线程,如果子类中有变量A,而不使用static修饰变量的话,每一个子类的对象都会有自己独立的变量A,只有static修饰A后,子类的对象才可以共享变量A。而实现Runnable接口的方式,如果只创建一个实现类的对象,并将这个对象传入Thread类,创建多个Thread类的对象来完成多线程,那么这多个Thread类对象实际就是调用一个实现类的对象而已,会共享到这个实现类的对象中的所有变量【需要注意线程安全问题】。实现接口的方式更适合用来处理多个线程要共享数据/该类已有父类的情况。
- 联系:Thread类中也实现了Runnable接口。
- 相同点:两者都需要重写run方法。线程的执行逻辑都在run()方法中。
5.2.4.实现Callable接口[可有返回值]
与Runnable相比,Callable接口中定义了一个额外的方法call(),该方法可以有返回值。
- 创建实现Callable接口的实现类
- 重写call()方法,该方法将作为线程执行体,并且有返回值。【支持泛型的返回值、可以抛出异常】
- 创建实现类的对象【表示多线程要执行的任务】
- 创建FutureTask类对象,将Callable接口实现类的对象作为构造器参数传递。【管理多线程运行的结果】
- 创建Thread对象,将FutureTask对象作为Thread对象的构造器参数传递。【表示线程】
- 可以通过FutureTask对象调用get方法获取多线程运行结果。【获取多线程运行结果】
public class Main {
public static void main(String[] args) {
//3.创建Callable接口实现类的对象
CallableTest callableTest = new CallableTest();
//4.将此Callable接口实现类的对象作为参数传递到FutureTask构造器中,创建FutureTask对象
FutureTask<Integer> futureTask = new FutureTask<>(callableTest);
//5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
Thread thread = new Thread(futureTask);
thread.start();
try {
//6.获取Callable中Call方法的返回值
Integer result = futureTask.get();
System.out.println(result); // 输出:45
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
}
//1.创建一个实现Callable的实现类
class CallableTest implements Callable<Integer> {
//2.实现call方法,将此线程需要执行的操作声明在call()中
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum+=i;
}
return sum;
}
}
5.2.5.线程池
5.2.5.1.线程池的体系结构
- 出现的原因:经常创建和销毁、使用量特别大的资源、比如并发情况下的线程、对性能影响很大。
- 设计思路: 提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。
- 优点:
- 提高相应速度【减少创建和销毁时间】
- 降低资源消耗【重复利用线程池中的线程,不需要每次都重新创建】
- 便于线程管理
- 实现原理
- 接受到一个任务时,判断线程池中现有存活的线程,是否有空闲
- 如果已创建的核心线程都有任务,则将任务放到队列中
- 如果队列满了,就判断当前线程池的线程数量是否达到最大值,没有的话创建新的线程并执行该任务
- 如果达到了最大的线程数量,则按照设定的拒绝策略处理该任务
5.2.5.2.线程池的创建方式
创建方式 | 描述 |
---|---|
Executors.newFixedThreadPool(int n) |
创建一个固定大小的线程池,该线程池中的线程数量固定为指定的大小 n ,当有任务提交时,如果线程池中的线程都在执行任务,新的任务会在任务队列中等待。 |
Executors.newCachedThreadPool() |
创建一个缓存线程池,该线程池中的线程数量会根据任务的数量动态调整,当有任务提交时,如果线程池中有空闲线程,则立即使用;如果没有,则创建新的线程执行任务。空闲线程在一定时间内没有被使用会被回收。 |
Executors.newSingleThreadExecutor() |
创建一个单线程的线程池,该线程池中只有一个核心线程,所有任务都在同一个线程中串行执行。适用于需要顺序执行任务的场景,例如事件触发器、定时任务等。 |
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler) |
使用 ThreadPoolExecutor 构造函数自定义配置线程池,可以指定核心线程数、最大线程数、线程空闲时间、任务队列等参数。可以根据实际需求灵活配置线程池。 |
ThreadPoolTaskExecutor (Spring Framework) |
在 Spring Framework 中使用 ThreadPoolTaskExecutor 类创建线程池,该类是 Spring 框架提供的用于管理线程池的工具类,具有更多的扩展性和配置选项,可以通过 Spring 的配置文件或 Java 代码进行灵活配置。 |
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue
-
corePoolSize:核心线程数,表示线程池中始终存活的线程数。【不能小于0】
-
maximumPoolSize:最大线程数,线程池中允许的最大线程数。【最大数量>=核心线程数量】
-
keepAliveTime:存活时间数值,表示的是一个线程没有任务执行时最多保持多长时间会终止。【不能小于0】
-
unit:存活时间单位,参数keepAliveTime的时间单位。【用TimeUnit指定】
- TimeUnit.DAYS: 天
- TimeUnit.HOURS:小时
- TimeUnit.MINUTES:分钟
- TimeUnit.SECONDS:秒
- TimeUnit.MILLISECONDS:毫秒
- TimeUnit.MICROSECONDS:微秒
- TimeUnit.NANOSECONDS:纳秒
-
workQueue:阻塞队列,用于存放等待执行任务的队列,均为线程安全。【不能为null】
- ArrayBlockingQueue:基于数组的有界阻塞队列,按照先进先出(FIFO)的顺序存储元素【不是一个严格的FIFO队列,多线程下不保证公平性】。当队列已满时,尝试插入元素会导致操作阻塞。
- LinkedBlockingQueue:基于链表的无界阻塞队列,按照先进先出(FIFO)的顺序存储元素。由于队列容量没有限制,因此不会导致插入操作阻塞但在队列为空时,尝试取出元素会导致操作阻塞。
- SynchronousQueue:同步队列,不存储元素,每个插入操作必须等待另一个线程的移除操作。这意味着插入操作和移除操作是同步的,如果没有消者线程等待取出元素,插入操作会一直阻塞。
- PriorityBlockingQueue:优先级的无界阻塞队列,元素按照优先级顺序存储,而非先进先出。它使用无锁的堆实现,保证了线程安全性和高效性。
- DelayQueue:基于优先级队列的无界阻塞队列,只有在延迟期满时才能从中提取元素。常用于定时任务调度场景,也是使用无锁的堆实现。
- LinkedTransferQueue:基于链表结构的无界阻塞队列,类似于 SynchronousQueue,但也包含非阻塞的 tryTransfer 方法,用于非阻塞地将元素转移给消费者线程。
- LinkedBlockingDeque:基于链表结构的双向阻塞队列,容量默认为 Integer.MAX_VALUE。它可以在队列的头部和尾部进行插入和移除操作,提供了更灵活的队列操作方式。
-
threadFactory: 线程工厂,主要用来创建线程,
Executors.defaultThreadFactory()
->默认正常优先级、非守护线程。也可以自己创建线程工厂。【不能为null】 -
handler: 拒绝策略,当队列满时,线程池会拒绝新任务。默认使用 AbortPolicy 策略。
- ThreadPoolExecutor.AbortPolicy: 丢弃任务并抛出 RejectedExecutionException 异常。【默认策略】
- ThreadPoolExecutor.DiscardPolicy: 丢弃任务,但是不抛出异常。【这是不推荐的做法】
- ThreadPoolExecutor.DiscardOldestPolicy: 抛弃队列中等待最久的任务[排在最前面],然后把当前任务加入队列中。
- ThreadPoolExecutor.CallerRunsPolicy: 调用任务的 run() 方法绕过线程池直接运行该任务。
5.2.5.3.线程池提交任务的方式
- execute(): 用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。excute()方法输入的任务是一个Runnable类的实例
- submit(): 用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法获取到任务的返回值。get()方法会阻塞当前线程直到任务执行完毕。而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完成。
Future<T> submit(Callable<T> task)
:接受一个Callable接口的实例,表示要执行的任务,并返回一个Future对象,可以通过对象的get()方法获取执行结果。Future<?> submit(Runnable task)
:接受一个Runnable接口的实例,表示要执行的任务,并返回一个Future对象,但对象的get()方法返回值总是为null。Future<T> submit(Runnable task, T result)
:与上一种类似,但可以提供一个结果作为参数,表示任务完成时的结果。
- submit()方法的使用注意事项:
submit()
方法返回一个Future
对象,通过该对象可以获取任务执行的结果,但是要注意Future
对象的get()
方法会阻塞当前线程,直到任务执行完成并返回结果,这可能会导致当前线程被堵塞。- 在多线程环境中,如果在循环中频繁调用
get()
方法,可能会降低程序的并发性能,甚至引发死锁等问题。因此,在使用submit()
方法提交任务时,需要谨慎考虑是否需要等待任务执行完成。
5.2.5.4.关闭线程池
-
调用线程池的
shutdown
或shutdownNow
方法来关闭线程池shutdown
:线程池的状态设置称SHUTDOWN,线程池会拒绝新的任务,关闭没有任务的那些空闲线程,并且等待已提交的任务执行完成。该方法用于需要等待线程都执行完成任务的场景。shutdownNow
:线程池的状态设置称STOP,线程池会立即尝试停止所有正在执行的任务,并且不再等待任务执行完毕,正在执行的任务会被中断,未执行的任务会被取消。
关闭原理:它们的原理是遍历线程池中的工作线程,然后逐个调用线程的
interrupt
方法来中断线程,所以无法响应中断的任务可能永远无法终止运行。 -
判断线程池状态:
isShutdown()
、isTerminated()
isShutdown()
:如果调用了上述的两个关闭之一,isShutdown()方法返回值为trueisTerminated()
:判断线程池是否已经完全终止。当所有任务都已关闭,即线程池关闭完成,isTerminated()方法返回值为true
5.2.5.5.线程池的监控
- 目的:方便在出现问题时,可以根据线程池的使用状态快速定位问题
- 监控方法:
ThreadPoolExecutor.getActiveCount()
:获取当前线程池中正在执行的任务数量ThreadPoolExecutor.getTaskCount()
:获取线程池中任务总数量ThreadPoolExecutor.getCompletedTaskCount()
:获取线程池中已完成的任务数量ThreadPoolExecutor.getLargestPoolSize()
:获取线程池中曾经创建过的最大线程数量
- 拓展线程池的监控方法:可以通过继承线程池来自定义线程池,重写线程池的beforeExecute、afterExecute方法和terminated方法,也可以在任务执行前、执行后和线程关闭前执行一些代码来进行监控。例如:监控任务的平均执行时间、最大执行时间和最小执行时间等。这几个方法在线程池里是空方法。
5.2.5.6.如何合理配置线程池的参数
- 任务的性质
- CPU密集型任务:任务以内存中的计算为主。避免线程上下文切换的成本。一般CPU是N核,就开N+1个线程
- IO密集型任务:尽量多开启一些线程并发做IO操作。因为在IO过程中,CPU几乎是闲置的。一般可能是2*CPU核数,当然根据下面的标准公式计算会好一些:
- 任务的优先级【高中低】:优先级不同的任务可以使用优先队列
PriorityBlockingQueue
来处理。它可以让优先级高的任务先执行。 - 任务的执行时间【长中短】:根据任务的执行时间、设置核心线程数、最大线程数、队列容量等参数,来确定线程池的配置。【当然,过大的线程池可能会导致系统资源过度占用,需要根据实际情况进行合理配置】
- 最好使用有界队列:有界队列能增强系统的稳定性和预警能力,可以根据需要设置大一点,比如几千。有一次,我们系统里后台任务线程池的队列和线程池的最大线程数量全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题
5.2.5.7.代码演示
public class ThreadPoolExectorTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
3,
6,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
runnable runnable = new runnable();
threadPoolExecutor.execute(runnable);
Future<Integer> submit = threadPoolExecutor.submit(new callable());
System.out.println(submit.get());
threadPoolExecutor.shutdown();
}
public static class runnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "执行了");
}
}
public static class callable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 1 + 1;
}
}
}
运行结果:
pool-1-thread-1执行了
2
6.线程的安全问题
6.1.前置知识
在认识线程的安全问题之前,我们需要先了解一下多线程的三大特性、JMM(Java Memory Model,即Java内存模型)以及活跃性问题的相关知识
- 线程的三大特性:
- 原子性:
- 即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 对于复合操作而言,synchronized,Lock可以保证原子性,而volatile关键字不能
- 可见性:
- 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- synchronized、Lock和volatile关键字都能保证可见性
- 有序性:
- 程序代码执行的结果不受JVM指令重排序的影响。
- synchronized、Lock和volatile关键字都能保证有序性
- 原子性:
总结:
synchronized可以保证原子性、可见性和有序性。(这里的有序性是有限的)
volatile关键字可以保证可见性、有序性,对于单独的volatile变量的读写操作,也能保证原子性。然而,volatile 不能保证复合操作的原子性,比如 volatile int a = 0; a++; 这种情况并不是原子的。需要使用 AtomicInteger 等原子类来保证原子性。
Lock锁机制可以同时保证以上三个特性,但需要手动管理锁的获取和释放。因此,在不需要Lock特定功能的情况下,一般推荐使用synchronized。
-
JMM:
- 关键概念
- 共享内存:即主存,所有线程共享,【堆区与方法区】
- 本地内存(线程):也称为"工作内存"。JVM给每一个线程都分配了一块内存区域(线程栈:有程序计数器、方法栈等),该块内存是线程独有的。
- JMM规定:线程不能直接操作主存,而是只能操作属于自己的那部分内存。如果多个线程间需要进行变量共享,必须经过主存进行同步。
- 由于JMM的限制,线程操作变量都需要经过以下几个基本步骤:
1.线程从主存中读取变量值到工作内存中
2.在工作内存中对变量进行修改操作
3.将操作后的结果同步回主存
- 由于JMM的限制,线程操作变量都需要经过以下几个基本步骤:
- 重排序:为了提高持续执行性能,编译器和CPU会对指令进行重排序。但是在多线程环境下,重排序可能导致线程安全问题,因此必须遵循 happens-before 原则来保证正确的执行顺序。
- hapens-before原则:这个原则描述了在多线程环境下,操作执行的先后顺序。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这个原则确保了正确的内存可见性和有序性。
- 关键概念
-
活跃性问题:
- 活跃性问题:活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。
- 死锁:多个线程相互等待对方持有的资源而无法继续执行,导致所有线程都无法完成任务。
- 活锁:线程不断重复相同的操作,但无法取得进展,因此任务无法完成,尽管线程在运行。
- 饥饿问题:一个或多个线程由于无法获取所需的资源而无法继续执行,尽管资源可用,但被其他线程占用,导致线程长时间无法完成任务。
- 活跃性问题:活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。
6.2.基本概念
- 衡量标准:如果同一个程序在单线程环境下与在多线程下执行的结果一致,就说明线程安全,反之则是线程不安全
- 所谓的线程安全问题,其本质在于线程对共享变量操作的原子性、可见性、有序性不能同时满足或者存在活跃性问题,因此解决线程安全问题的关键就在于同时满足三大特性并避免活跃性问题出现。解决线程安全问题可能需要采取多种手段,包括使用锁机制、原子类、volatile 关键字、并发集合等,具体的方法取决于具体的场景和需求。
6.3.线程的不安全的原因
6.3.1.原子性
- 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 原子操作:它是在单个步骤内执行完毕,不可中断的操作。这意味着原子操作的执行过程是连续的,不会被线程切换、中断或者其他因素打断。原子操作的执行要么全部完成,要么完全不执行,没有中间状态。
看这样一个例子,如下图
这最终导致的结果是一张票被售卖了两次,这样就具有很大的风险性。售票的过程被分成三个可分割的步骤执行,不具有原子性。
注意:我们在写一行Java代码可能不是原子性的,因为它编译成字节码,或者由JVM把字节码翻译成机器码后就可能不是一行,也就是多条执行操作。
在并发编程中很多操作都不是原子操作,出个小题目:
int i = 0;// 操作1
i++;// 操作2
int j = i;// 操作3
i = i + 1;// 操作4
- 操作1:这是原子操作,因为它是一个单一的、不可分割的步骤。
- 操作2:这不是原子操作,这实际上是一个 “read-modify-write” 操作,它包括了读取 i 的值、修改 i 的值和写回 i 的值。
- 操作3:这是一个原子操作,因为它是一个单一的、不可分割的步骤。
- 操作4:这不是原子操作,和 i++ 一样,这也是一个 “read-modify-write” 操作。
在单线程环境下上述是个操作都不会出现问题,但在多线程环境下,如果不加锁或者使用原子类的话,可能会出现意料之外的值。我们来测试一下,看看输出结果。
public class YuanziDeo {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
int numThreads = 2;
int numIncrementsPerThread = 100000;
Thread[] threads = new Thread[numThreads];
for (int j = 0; j < numThreads; j++) {
threads[j] = new Thread(() -> {
increase(numIncrementsPerThread);
});
threads[j].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final value of i = " + i);
System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
}
private static void increase(int numIncrementsPerThread) {
for (int k = 0; k < numIncrementsPerThread; k++) {
i++;
}
}
}
输出如下:
Final value of i = 118667
Expected value = 200000
i 期望的值为 200000,但实际跑出来的是 118667,这证明 i++ 不是一个原子操作,
i++,i- -操作被分成三步执行:
- 从主存把数据读取到本地内存
- 对数据进行更新操作
- 再把更新后的操作写回主存
在多线程中,这三个步骤再执行时,线程可以被中断,导致数据更新出现问题,eg:线程1拿了 i = 100 ,执行 ++ 操作后,本地内存中i = 101,还没来得及写回主存,被线程2抢到CPU执行权,线程2从主存中拿到的 i = 100(还是100,还没有被线程1更新),假设后面的流程是线程2执行完了3个步骤后把主存中的i更新为101后,线程1又抢到CPU执行权,完成步骤3,更新完主存中的i还是等于101,这就相当于两次++操作最终i只加了1。
6.3.2.可见性
可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
来看这段代码
public class ThreadDemo17 {
private static boolean flag = true;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println("线程1:开始执行 " + LocalDateTime.now());
while (flag) {
}
System.out.println("线程1:结束执行 " + LocalDateTime.now());
});
thread1.start();
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2:修改flag = false " + LocalDateTime.now());
flag = false;
});
thread2.start();
}
}
运行结果:
可以看到,线程2将flag修改为false,线程1始终未结束执行【线程1中的flag并没有得到更新,始终是true】,这就是内存可见性问题。
多个线程工作的时候都是在自己的工作内存来执行操作的,线程之间是不可见的:
- 线程之间的
共享变量存在主存中
- 每一个
线程
都有自己的工作内存
- 线程读取共享变量时,先把变量从主内存拷贝到自己的工作内存(CPU寄存器),再从工作内存读取数据
- 线程修改共享变量时,先修改工作内存中的变量值,再同步到主内存【并不能及时将新值刷新到主内存中】
6.3.3.有序性
了解重排序:在计算机系统内部,程序中的指令并非严格按照源代码的顺序执行。当一个CPU核心通过流水线技术处理指令时,若前条指令未完成但不影响后续指令的执行,处理器可能会提前执行下一条或多条指令。同样地,编译器在生成机器代码的过程中也可能出于化目的重新安排源代码的执行顺序。这种现象就被称为指令重排序。(在java内存模型中,允许编译器和处理器对指令进行重排序,重排过程不会影响单线程程序的执行,但是会影响多线程并发执行的正确性)
比如有这样三步操作:(1)去前台取U盘 (2)去教室写作业 (3)去前台取快递
JVM会对指令优化,也就是重排序,新的顺序为(1)(3)(2),这样来提高效率虽然重排序提高了CPU利用率和程序执行效率,但它也可能引入了潜在的多线程问题,尤其是在没有正确同步的情况下,可能导致不可预测的行为和数据竞争。为此,Java内存模型(JMM)通过happens-before规则来限制重排序,并确保在正确同步的多线程环境中,各线程能观察到一致且符合预期的内存状态。
当程序没有进行正确的同步控制时,就可能出现数据竞争问题。数据竞争指的是在一个线程内写入变量的同时,另一个线程读取了同一个变量,且这两个操作之间没有通过任何同步机制来确保执行顺序。这种情况下,程序的行为可能变得不可预测,例如读取到未更新的数据或者状态混乱。
来看这段代码
// 示例:数据竞争
class DataRaceExample {
int sharedValue = 0;
Thread writerThread = new Thread(() -> {
sharedValue = 1; // 写操作
});
Thread readerThread = new Thread(() -> {
int localCopy = sharedValue; // 读操作
System.out.println("Reader sees: " + localCopy);
});
public void startThreads() {
writerThread.start();
readerThread.start(); // 数据竞争,因为没有同步措施
}
}
在这个示例中,读者线程可能会在写者线程完成赋值之前就读取sharedValue,从而导致结果不确定。
有序性:即程序的执行顺序按照代码的先后顺序执行。(在java内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响单线程程序的执行,但是会影响多线程并发执行的正确性)
6.3.4.活跃性问题
上面讲到的问题都可以采取加锁的方式来解决,但是如果加锁不当也容易引入其他问题,比如『死锁』。
在讲『死锁』之前,我们需要先引入另外一个概念:活跃性问题
。
活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。
活跃性问题一般有这样几类:死锁
,活锁
,饥饿问题
。
- 死锁:
死锁是指多个线程因为环形等待锁的关系而永远地阻塞下去。
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1 acquired lock1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1 waiting for lock2");
synchronized (lock2) {
System.out.println("Thread 1 acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2 acquired lock2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2 waiting for lock1");
synchronized (lock1) {
System.out.println("Thread 2 acquired lock1");
}
}
});
thread1.start();
thread2.start();
}
}
输出结果:
Thread 1 acquired lock1
Thread 2 acquired lock2
Thread 2 waiting for lock1
Thread 1 waiting for lock2
- 活锁:
死锁是两个线程都在等待对方释放锁而导致阻塞。而活锁
的意思是线程没有阻塞,还活着呢。当多个线程都在运行并且修改各自的状态,而其他线程又依赖这个状态,就导致任何一个线程都无法继续执行,只能重复着自身的动作,于是就发生了活锁。
举一个生活中的例子,大家平时在走路的时候,迎面走来一个人,两个人互相让路,但是又同时走到了一个方向,如果一直这样重复着避让,这俩人就发生了活锁,学到了吧,嘿嘿。
public class LivelockExample {
private static boolean shouldTakeStep1 = true;
private static boolean shouldTakeStep2 = true;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (true) {
if (shouldTakeStep1) {
System.out.println("Thread 1 takes step 1");
shouldTakeStep1 = false;
shouldTakeStep2 = true;
} else {
System.out.println("Thread 1 waits for Thread 2 to take step 2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread thread2 = new Thread(() -> {
while (true) {
if (shouldTakeStep2) {
System.out.println("Thread 2 takes step 2");
shouldTakeStep2 = false;
shouldTakeStep1 = true;
} else {
System.out.println("Thread 2 waits for Thread 1 to take step 1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
thread1.start();
thread2.start();
}
}
输出结果:
Thread 1 takes step 1
Thread 1 waits for Thread 2 to take step 2
Thread 2 takes step 2
Thread 2 waits for Thread 1 to take step 1
Thread 1 takes step 1
Thread 1 waits for Thread 2 to take step 2
Thread 2 waits for Thread 1 to take step 1
Thread 2 takes step 2
Thread 2 waits for Thread 1 to take step 1
......
- 饥饿:
如果一个线程无其他异常却迟迟不能继续运行,那基本上是处于饥饿状态了
常见的有几种场景- 高优先级的线程一直在运行消耗CPU,所有低优先级线程一直处于等待
- 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问
public class StarvationExample {
public static void main(String[] args) {
Runnable runnable = () -> {
synchronized (StarvationExample.class) {
while (true) {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000); // 模拟线程持续运行
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread highPriorityThread = new Thread(runnable);
highPriorityThread.setPriority(Thread.MAX_PRIORITY);
Thread lowPriorityThread = new Thread(runnable);
lowPriorityThread.setPriority(Thread.MIN_PRIORITY);
highPriorityThread.start();
lowPriorityThread.start();
}
}
7.线程的同步
上面我们提到了多线程造成的不安全问题,那么我们应该怎么解决呢?
解决多线程安全问题的关键就在于实现多线程的同步,即制某个资源在同一时刻只能被一个线程访问。
7.1.volatile关键字
volatile
是用来修饰变量
的,它的作用是保证可见性、有序性
注意:不能保证原子性,对于n++,n--来说,用volatile修饰n也是线程不安全的
实现原理
- 代码在写入volatile修饰的变量的时候,改变线程工作内存中volatile变量副本的值,改变后的值会立即写回到主存,并且会立即使其他线程的工作内存中对应的缓存无效。这确保了其他线程在下次读取该变量时能够看到最新的值。
- 代码在读取volatile修饰的变量的时候,会从主存中读取volatile变量的最新值到线程工作内存中,再从工作内存中读取volatile变量的副本
volatile解决可见性问题
public class ThreadDemo17 {
private volatile static boolean flag = true;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println("线程1:开始执行 " + LocalDateTime.now());
while (flag) {
}
System.out.println("线程1:结束执行 " + LocalDateTime.now());
});
thread1.start();
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2:修改flag = false " + LocalDateTime.now());
flag = false;
});
thread2.start();
}
}
运行结果:
给变量flag加上volatile修饰后,线程1能够接收到flag的改变,从而结束了执行,解决了内存的可见性问题。
volatile关键字的缺点
volatile关键字虽然可以解决内存的可见性和指令重排序的问题,但解决不了原子性问题,对于 ++ 和 -- 操作的线程非安全问题依然解决不了,比如以下代码:
public class ThreadDemoVolatile {
static class Counter {
// 变量
private volatile int number = 0;
// 循环次数
private final int MAX_COUNT;
public Counter(int MAX_COUNT) {
this.MAX_COUNT = MAX_COUNT;
}
// ++ 方法
public void increase() {
for (int i = 0; i < MAX_COUNT; i++) {
number++;
}
}
// -- 方法
public void desc() {
for (int i = 0; i < MAX_COUNT; i++) {
number--;
}
}
public int getNumber() {
return number;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter(100000);
Thread thread1 = new Thread(counter::increase);
thread1.start();
Thread thread2 = new Thread(counter::desc);
thread2.start();
// 等待线程执行完成
thread1.join();
thread2.join();
System.out.println("最终结果:" + counter.getNumber()); // 不一定等于0
}
}
7.2.synchronized锁
我们知道,在Java中,每一个对象都有一把唯一的锁
,这也是synchronized实现线程同步的基础。【唯一的锁也就是对象的监视器锁,也称为内部锁或互斥锁】
synchronized
是基于对象头加锁
的,它的作用是保证了原子性、可见性、有序性
(这里的有序性是有限的)
特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位,一个对象在同一时间只能有一个线程获取到该对象的锁。如果锁已经被其他线程获取,那么当前线程就会进入到阻塞状态,直到它获取到了锁。
同步原理
- 当线程进入一个使用synchronized关键词修饰的方法或代码块时,它会尝试获取对象的锁。
- 如果对象的锁被其他线程持有,那么当前线程就会被阻塞,直到它获取到了锁。
- 当线程执行完了synchronized方法或代码块后,会释放对象锁,这样其他等待获取该锁的线程就有机会执行了。
优点
-
互斥性
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 方法或代码块时, 其他线程如果也执行到同一个对象的 synchronized 方法或代码块,就会阻塞等待
进入synchronized方法或代码块,相当于 加锁
退出synchronized方法或代码块,相当于 解锁看下图理解加锁过程:
阻塞等待:
针对每一把锁(监视器锁),JVM内部都维护了一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试获取这把锁,但由于锁已经被占用,这些线程就会进入阻塞状态,加入到该锁的等待队列中。直到持有锁的线程释放锁,JVM将从等待队列中唤醒一个或多个线 程,使其尝试再次获取锁。如果这些线程成功获取锁,它们将继续执行;否则,将继续阻塞等待。 -
刷新主存
synchronized的工作过程:
获得互斥锁
从主存拷贝最新的变量到工作内存
对变量执行操作
将修改后的共享变量的值刷新到主存
释放互斥锁 -
可重入性
synchronized是可重入锁
同一个线程可以多次获取同一个对象的锁,即允许线程对同一个锁进行嵌套调用【避免了死锁的发生】可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个"加锁次数",对于第一次加锁,记录当前获取锁的线程并且次数加一。但是后续该线程继续申请加锁的时候,并不会直接加锁,而是将记录的"加锁次数加1",后续释放锁的时候,次数减1,直到次数为0才是真正的释放锁。
可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率)
-
内置了锁定和释放机制
synchronized关键字内置了锁定和释放机制,使用起来比较方便,不需要手动进行锁的管理
缺点
- 性能问题
虽然 synchronized 关键字能够保证线程安全,但是它的性能相对较差,因为在获取锁和释放锁的过程中会涉及到一定的系统开销。
如果是多个线程需要同时进行读操作,一个线程读操作时其他线程只有等待 - 方法较少
无法知道是否成功获取到锁,无法知道锁是否被其他线程持有。
synchronized实现线程同步的形式
形式 | 特点 |
---|---|
实例同步方法 |
锁的是当前实例对象 ,执行同步代码前必须获得当前实例的锁 。这意味着,同一个类的不同实例对象之间不会互斥。 |
静态同步方法 |
锁的是当前类的Class对象 ,执行同步代码前必须获得当前类的Class对象的锁 。这意味着,无论有多少实例对象,它们共享同一把锁。 |
同步代码块 |
锁的是括号里的对象 ,对给定对象加锁,执行同步代码块必须获得给定对象的锁 。这提供了更灵活的锁定范围,允许更细粒度的控制。 |
- 实例同步方法:
public synchronized void doSomething() { public void doSomething() { ... synchronized (this){ ... <==> ... } } } }
- 静态同步方法:
public static synchronized void doSomething() { public static void doSomething() { ... synchronized (this){ ... <==> ... } } } }
- 同步代码块:
需要显示指定对哪个对象加锁(Java中任意对象都可以作为锁对象)synchronized (对象) { //... }
为什么要使用同步代码块?
- 在某些情况下,我们编写的方法体可能比较庞大,同时又有一些耗时的操作,如果对整个方法体进行同步,效率会大大降低。所以我们希望能够只同步必要的代码块,对于一些不需要同步的或者耗时较长的操作,放到同步代码块之外,比如:
public class Synchronized implements Runnable{
public void running() throws InterruptedException {
for (int i = 0; i < 3; i++) {
System.out.println("这是耗时操作。");
}
//需要同步的代码块写下面
synchronized (this){
System.out.println("1");
Thread.sleep(1000);
System.out.println("2");
}
}
@Override
public void run() {
try {
running();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
Synchronized sync = new Synchronized();
new Thread(sync).start();
new Thread(sync).start();
}
}
运行结果:
这是耗时操作。
这是耗时操作。
这是耗时操作。
1
这是耗时操作。
这是耗时操作。
这是耗时操作。
2
1
2
结果表明,需要同步的代码块确实实现了同步。
synchronized解决原子性问题
回到线程安全的原子性问题,为避免以上的问题发生,我们给 increase() 方法加上synchronized 关键字,使得两个线程无法同时调用increase() 方法,以保证++操作的三个步骤中的任何一步都不会被另外一个线程打断,这样,"i++"操作就永远不会因为线程切换而出错。
代码如下
public class YuanziDeo {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
int numThreads = 2;
int numIncrementsPerThread = 100000;
Thread[] threads = new Thread[numThreads];
for (int j = 0; j < numThreads; j++) {
threads[j] = new Thread(() -> {
increase(numIncrementsPerThread);
});
threads[j].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final value of i = " + i);
System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
}
private static synchronized void increase(int numIncrementsPerThread) {
for (int k = 0; k < numIncrementsPerThread; k++) {
i++;
}
}
}
运行结果:
Final value of i = 200000
Expected value = 200000
这里用到的是静态同步方法,线程1和线程2进入synchronized方法时,使用的是同一把锁。
synchronized需要注意的问题
请看以下代码
public class Synchronized implements Runnable{
@Override
public void run() {
try {
running();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
//用同一个类创建两个对象
Synchronized sync1 = new Synchronized();
new Thread(sync1).start();
Synchronized sync2 = new Synchronized();
new Thread(sync2).start();
}
public synchronized void running() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " 进入了synchronized修饰的实例同步方法 ");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " 准备离开synchronized修饰的实例同步方法 ");
}
}
运行结果:
Thread-0 进入了synchronized修饰的实例同步方法
Thread-1 进入了synchronized修饰的实例同步方法
Thread-1 准备离开synchronized修饰的实例同步方法
Thread-0 准备离开synchronized修饰的实例同步方法
为什么在Thread-0还没有离开synchronized修饰的实例同步方法时,Thread-1就进入synchronized修饰的实例同步方法呢?
这是因为synchronized修饰的实例同步方法锁的对象是this对象,而使用两个对象去访问,不是同一把锁。导致没有起到线程同步的效果。
如果我们用同一个对象访问://只创建一个对象 Synchronized sync = new Synchronized(); new Thread(sync).start(); new Thread(sync).start();
结果就是同步的
注:为了确保所有线程都能看到共享数据的最新值,因此所有执行读写操作的线程都必须在同一个锁上同步。
7.3.Lock锁
前面使用的synchronized关键字可以实现多线程间的同步问题,其实,在JDK1.5后新增的ReentrantLock
类同样可以实现这个功能,而且在用法上比synchronized关键字更灵活。
ReentrantLock (重入锁),是实现Lock接口的一个类。
ReentrantLock 支持两种锁:公平锁和非公平锁。【默认情况下(不传参),ReentrantLock 创建的是非公平锁】
ReentrantLock的源码分析
支持重入性
要想支持重入性,就要解决两个问题:
- 当一个线程尝试获取已经被它自己持有的锁时,应当允许它成功获取锁。这意味着锁的获取操作必须能够识别当前持有锁的线程,并允许该线程再次获取锁。
- 由于锁可能会被一个线程多次获取,因此锁的释放操作必须进行计数。只有当一个线程释放锁的次数与它获取锁的次数相等时,锁才算是真正被释放,从而允许其他线程获取该锁。
针对第一个问题,我们来看看 ReentrantLock 是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为内部类 Sync 的 nonfairTryAcquire 方法:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//1. 如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3. 再次获取,计数加一
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这段代码的逻辑很简单,具体请看注释。为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加 1 返回 true,表示可以再次获取成功。每次重新获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的呢?(依然还是以非公平锁为例)核心方法为 tryRelease:
protected final boolean tryRelease(int releases) {
//1. 同步状态减1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//2. 只有当同步状态为0时,锁成功被释放,返回true
free = true;
setExclusiveOwnerThread(null);
}
// 3. 锁未被完全释放,返回false
setState(c);
return free;
}
代码的逻辑请看注释,需要注意的是,重入锁的释放必须得等到同步状态为 0 时锁才算成功释放,否则锁仍未释放。如果锁被获取了 n 次,释放了 n-1 次,该锁未完全释放返回 false,只有被释放 n 次才算成功释放,返回 true。到现在我们可以理清 ReentrantLock 重入性的实现了,也就是理解了同步语义的第一条。
公平锁和非公平锁
ReentrantLock 支持两种锁:公平锁和非公平锁。
- 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- ReentrantLock 的构造方法无参时是构造非公平锁,源码为:
public ReentrantLock() { sync = new NonfairSync(); }
- ReentrantLock 的构造方法有参(boolean fair)时,true为公平锁,false为非公平锁,源码为:
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
在非公平锁获取时(nonfairTryAcquire 方法),只是简单的获取了一下当前状态然后做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。
我们来看看公平锁的处理逻辑是怎样的,核心方法为:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这段代码的逻辑与 nonfairTryAcquire 基本上一致,唯一的不同在于增加了 hasQueuedPredecessors 的逻辑判断,从方法名就可以知道该方法用来判断当前节点在同步队列中是否有前驱节点的,如果有前驱节点,说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点,才有做后面逻辑判断的必要性。
公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁
ReentrantLock 的使用
ReentrantLock基本用法:
public class ThreadLock {
public static void main(String[] args) {
// 1.创建锁对象
Lock lock = new ReentrantLock();
// 2.加锁
lock.lock();
try {
// 业务代码
System.out.println("hello");
} finally {
// 3.释放锁
lock.unlock();
}
}
}
注意事项:
在使用ReentrantLock时,必须手动加锁,手动释放锁。
- 锁必须在try代码块开始之前获取,或者在try代码块的首行【加锁之前不能有异常抛出】。否则会导致以下两个问题:
- 如果锁在try代码块里面,因为try代码中的异常导致加锁失败,还会执行finally释放锁的操作,进而引发 IllegalMonitorStateException 异常。
- unlock 异常会覆盖 try 里面的业务异常,增加排查错误的难度。
- ReentrantLock 的锁必须在finally 中手动释放。【这确保了无论 try 块内的代码是否抛出异常,锁都会被释放,防止死锁】
错误❎示例:
Lock lock = new XxxLock();
// ...
try {
// 如果在此抛出异常,会直接执行 finally 块的代码,导致 lock.lock() 没有被执行
doSomething();
// 不管锁是否成功,finally 块都会执行
lock.lock();
doOthers();
} finally {
lock.unlock();
}
正确✅示例:
Lock lock = new XxxLock();
// ...
lock.lock();
try {
doSomething();
doOthers();
} finally {
lock.unlock();
}
ReentrantLock解决原子性问题
ReentrantLock 的使用方式与 synchronized 关键字类似,都是通过加锁和释放锁来实现同步的。我们来看看 ReentrantLock 的使用方式,以非公平锁为例:
public class ReentrantLockTest {
private static final ReentrantLock lock = new ReentrantLock();
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
运行结果:
20000
可以看到,两个线程对 count 变量进行了 20000 次累加操作,说明 ReentrantLock 是支持原子性的。我们再来看看公平锁的使用方式,只需要将 ReentrantLock 的构造方法改为公平锁即可:
private static final ReentrantLock lock = new ReentrantLock(true);
运行结果:
20000
可以看到,公平锁的运行结果与非公平锁的运行结果一致,这是因为公平锁的实现方式与非公平锁的实现方式基本一致,只是在获取锁时增加了判断当前节点是否有前驱节点的逻辑判断。
7.4.ReentrantLock 与 synchronized 的区别
-
类 vs 关键字:
- ReentrantLock 是一个类,它提供了更多的灵活性和功能,如支持公平锁和非公平锁、可中断的获取锁、超时获取锁等。而 synchronized 是 Java 中的关键字,用于实现同步,其使用简单,但功能相对受限。
-
多路选择通知 vs 单路通知:
- ReentrantLock 可以与多个 Condition 对象一起使用,实现了多路选择通知,使得在某些情况下更灵活。而 synchronized 关键字只能通过 wait 和 notify/notifyAll 方法唤醒一个线程或者全部线程,这是一种单路通知。
-
手动释放锁 vs 自动释放锁:
- 在使用 ReentrantLock 时,需要手动调用
unlock()
方法来释放锁,通常在finally
块中进行,以确保在发生异常时锁能够被正确释放。而 synchronized 关键字在同步块执行完毕后会自动释放锁,无需手动操作。
- 在使用 ReentrantLock 时,需要手动调用
-
性能:
- 在高并发的情况下,ReentrantLock 通常提供了更好的性能,特别是在竞争激烈的场景下。因为 ReentrantLock 具有更细粒度的控制能力,可以避免某些情况下的锁竞争和线程饥饿现象。但是,随着 JDK 版本的升级,synchronized 的性能已经得到了很大的改进,在某些情况下,性能差距已经不太明显。
总的来说,ReentrantLock 提供了更多的功能和灵活性,但也需要更多的注意和管理。而 synchronized 关键字虽然使用简单,但功能相对受限。在选择使用时,需要根据具体的需求和场景进行权衡和选择。
7.5.原子类
基本概念
在Java的java.util.concurrent
包中,除了提供底层锁,并发同步等工具类之外,还提供了一组原子操作类,大多以Atomic
开头,它们位于java.until.concurrent.atomic
包中。
所谓原子操作类,就是这个操作要么全部执行成功,要么全部执行失败【
保证原子性
】,是保证并发编程安全的重要一环。
相比通过synchronized
和Lock
等方式实现的线程安全同步操作,原子类的实现机制则安全不同。它采用的是通过无锁(lock-free)的方式来实现线程安全访问,底层原理主要是基于CAS
操作来实现。
某些业务场景下,通过原子类来操作,即可实现线程安全的要求,又可以实现高效的并发性能,同时让编程方面更加简单。
常用原子操作类
虽然原子操作类很多,但是大体的用法基本类似,只是针对不同的数据类型进行了单独适配,这些原子类都可以保证多线程下数据的安全性,使用起来也比较简单。基本类型
基本类型的原子类,也是最常用的原子操作类,分为以下三种基础类型:
AtomicBoolean
:布尔类型的原子操作类AtomicInteger
:整数类型的原子操作类AtomicLong
:长整数类型的原子操作类
以AtomicInteger
为例,其基本用法如下:
方法 | 作用 |
---|---|
int get() |
获取当前值 |
void set(int newValue) |
设置当前值为newValue |
int getAndIncrement() |
获取当前值,并自增1(先获取再自增) |
int getAndDecrement() |
获取当前值,并自减1(先获取再自减) |
int incrementAndGet() |
自增1,并获取当前值(先自增再获取) |
int decrementAndGet() |
自减1,并获取当前值(先自减再获取) |
int getAndAdd(int delta) |
获取当前值,并增加delta(先获取再增加) |
int addAndGet(int delta) |
增加delta,并获取当前值(先增加再获取) |
int getAndSet(int newValue) |
获取当前值,并设置当前值为newValue |
boolean compareAndSet(int expect, int update) |
如果当前值等于expect,则设置当前值为update,并返回true,否则返回false。 直接使用CAS方法【核心方法】 |
代码示例:
public class Main {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger();
// 先获取值,再自增,默认初始值为0
int v1 = atomicInteger.getAndIncrement();
System.out.println("v1:"+v1);
// 获取自增后的ID值
int v2 = atomicInteger.incrementAndGet();
System.out.println("v2:"+v2);
// 获取自减后的ID值
int v3 = atomicInteger.decrementAndGet();
System.out.println("v3:"+v3);
// 使用CAS方式,将就旧值更新成 10
boolean v4 = atomicInteger.compareAndSet(v3, 10);
System.out.println("v4:"+v4);
// 使用CAS方式,更新失败的情况
boolean v5 = atomicInteger.compareAndSet(v3, 30);
System.out.println("v5: "+v5);
// 先增加再获取
int v6 = atomicInteger.addAndGet(-5);
System.out.println("v6: "+v6);
// 获取最新值
int v7 = atomicInteger.get();
System.out.println("v6:"+v7);
}
}
运行结果:
v1:0
v2:2
v3:1
v4:true
v5: false
v6: 5
v6:5
原子操作解决原子性问题
// 初始化一个原子操作类
private static AtomicInteger a = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
final int threads = 10;
CountDownLatch countDownLatch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
// 采用原子性操作累加
a.incrementAndGet();
}
countDownLatch.countDown();
}).start();
}
// 阻塞等待10个线程执行完毕
countDownLatch.await();
// 输出结果值
System.out.println("结果值:" + a.get());
}
运行结果:
结果值:10000
从结果可见,原子操作类也可以实现线程安全。关于底层实现原理是CAS操作,这里不再赘述。
与synchronized
和Lock
等实现方式相比,原子操作类因为采用无锁的方式实现,因此在某些场景下可以带来更高的执行效率。
数组类型
数组类型的原子操作类,并不是指对数组本身的原子操作,而是对数组中的元素进行原子性操作,这一点需要特别注意,如果要针对整个数组进行更新,可以采用对象引入数据类型的原子操作类进行处理。JDK提供了以下三个数组类型的原子类:
AtomicIntegerArray
:整型数组类型的原子操作类AtomicLongArray
:长整型数组类型的原子操作类AtomicReferenceArray <T>
:引用类型数组类型的原子操作类
相比与基本类型中的AtomicInteger
,方法大致相同,每个方法都增加了一个参数 int i
【第一个参数】,表示操作的数组下标。
以AmoticIntegerArray
为例,其基本用法如下:
public static void main(String[] args) throws InterruptedException {
int[] value = new int[]{0,3,5};
AtomicIntegerArray array = new AtomicIntegerArray(value);
// 将下标为[0]的元素,原子性操作加1
array.getAndIncrement(0);
System.out.println("下标为[0]的元素,更新后的值:" + array.get(0));
}
运行结果:
下标为[0]的元素,更新后的值:1
引用类型
上文提到的基本类型只能更新一个变量,如果需要原子性更新多个变量,这个时候可以采用对象引用类型的原子操作类,将多个变量封装到一个对象中。JDK同样提供了以下三种引用类型原子类:
AtomicReference <T>
:引用类型原子操作类AtomicStampedReference <T>
:带有版本号的引用类型原子操作类,可以解决ABA问题(即变量从A变成B再变成A的问题,导致无法检测到中间的变化)AtomicMarkableReference <T>
:带有标记位的引用类型原子操作类,可以用于标记对象的某种状态(如是否已处理)
以AtomicReference
为例,其基本用法如下:
public class Main {
public static void main(String[] args) {
// 设置初始值
AtomicReference<User> atomicUser = new AtomicReference<>();
User user1 = new User("张三",23);
atomicUser.set(user1);
// 采用CAS方式,将user1更新成user2
User user2 = new User("李四",24);
atomicUser.compareAndSet(user1,user2);
System.out.println("更新后的对象:" + atomicUser.get().toString());
}
public static class User{
private String name;
private int age;
public User() {
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return "User{name = " + name + ", age = " + age + "}";
}
}
}
运行结果:
更新后的对象:User{name = 李四, age = 24}
字段类型
某种场景下,可能你只想要原子性更新对象中的某个属性值,此时可以采用字段类型的原子操作类。JKD为我们提供了以下三种字段类型的原子类:
AtomicIntegerFieldUpdater <T>
:原子更新整型字段AtomicLongFieldUpdater <T>
:原子更新长整型字段AtomicReferenceFieldUpdater <T>
:原子更新引用类型字段
需要注意的是:这些字段类型的原子类需要满足以下条件才可以使用
- 被操作的字段不能是 static 类型
- 被操作的字段不能是 final 类型
- 被操作的字段必须被声明为 volatile 类型
- 属性必须对于当前的 Updater 对象可见,简单的说就是尽量使用 public 修饰字段
以AtomicIntegerFieldUpdater
为例,构造一个整数类型的属性引用,具体用法如下:
public class Main {
public static void main(String[] args) {
// 设置初始值
User user = new User("张三",23);
AtomicIntegerFieldUpdater<User> updater = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
// 将 age 的年龄原子性操作加1
updater.getAndIncrement(user);
System.out.println("更新后的属性值:" + updater.get(user));
}
public static class User {
private String name;
// age 要用 public、volatile 修饰
public volatile int age;
public User() {
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
}
运行结果:
更新后的属性值:24
累加器类型
累加器类型的原子操作类,是从JDK1.8开始加入的,专门用来执行数值类型的数据累加操作,性能更好。
它的实现原理与基本数据类型的原子类略有不同,当多线程竞争时采用分段累加的思路来实现目标值,在多线程环境中,它比基本数据类型的原子类性能要高出不少,特别是写多的场景。
JDK为我们提供了以下四种累加器类型的原子类:
LongAdder
:用于累加long类型数据DoubleAdder
:用于累加double类型数据LongAccumulator
:用于累加long类型数据,并且可以自定义函数操作DoubleAccumulator
:用于累加double类型数据,并且可以自定义函数操作
以LongAdder
为例,具体用法如下:
public static void main(String[] args) {
LongAdder adder = new LongAdder();
// 自增加 1,默认初始值为0
adder.increment();
adder.increment();
adder.increment();
System.out.println("最新值:" + adder.longValue());
}
运行结果:
最新值:3
原子操作的底层原理
这里不详细介绍其原理,如果你对ThreadLocal类的原理感兴趣,可以先参考:CAS机制详解
8.线程的通信
线程通信是指多个线程之间共享信息或数据,以协调它们的执行。这在并发编程中非常重要,因为需要确保线程之间的正确协作,以避免竞态条件、死锁等问题。
8.1.volatile可见性
同上,可以解决线程的可见性和有序性问题,但不能保证操作的原子性。
8.2.锁与同步
同上,通过synchronized
关键字和Lock
锁保证多个线程正确访问共享数据。
8.3.等待/通知机制
上面一种是基于"锁"的方式,线程需要不断地去尝试获得锁,如果失败了,再继续尝试。这可能会消耗服务器资源。
而基于对象的等待/通知机制是一种更加轻量化的方式,它允许一个或多个线程在满足特定条件下进入等待状态,而在其他线程完成某个操作后通过发送通知的方式唤醒这些等待中的线程。这一机制主要依赖java.lang.Object
类提供的wait()
、notify()
和notifyAll()
方法来实现。
wait()
:当前线程调用该方法时,会释放当前线程所持有的锁,并进入等待状态,直到其他线程调用notify()
或notifyAll()
方法唤醒当前线程。notify()
:随机唤醒一个正在等待该对象监视器的线程。notifyAll()
:唤醒所有正在等待该对象监视器的线程。
注意:
在使用等待/通知机制时,必须确保在synchronized
修饰的方法或代码块内调用这些方法,因为只有持有对象锁的线程才可以执行它们,否则会抛出IllegalMonitorStateException
异常。
此外,在调用wait()
方法后,线程在被唤醒后需要重新获得锁才能继续执行。
此外:
Lock接口的实现类ReentrantLock
也提供了类似的机制。通过实现Condition
接口,也可以实现等待/唤醒机制,相比于synchronized使用Object类的三个方法来实现线程的阻塞和运行两个状态的切换,ReentrantLock使用Condition阻塞队列的await()
、signal()
、signalAll()
三个方法来实现线程阻塞和运行两个状态的切换,进而实现线程间的通信。这些方法的使用和synchronized的使用类型,不再赘述。
经典范式(生产者-消费者):
class SharedResource {
private int data;
private boolean available = false;
public synchronized void produce(int value) throws InterruptedException {
while (available) {
wait(); // 如果数据可用,等待消费者消费
}
data = value;
System.out.println("Produced: " + data);
available = true;
notify(); // 通知消费者可以消费数据
}
public synchronized void consume() throws InterruptedException {
while (!available) {
wait(); // 如果数据不可用,等待生产者生产
}
System.out.println("Consumed: " + data);
available = false;
notify(); // 通知生产者可以生产数据
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 3; i++) {
resource.produce(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 3; i++) {
resource.consume();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
}
}
运行结果:
Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
Produced: 3
Consumed: 3
在这个示例中:
- 生产者线程生成数据并调用produce()方法。produce()方法在数据可用时等待,并在生成新数据后通知消费者。
- 消费者线程消费数据并调用consume()方法。consume()方法在数据不可用时等待,并在消费数据后通知生产者。
8.4.join方法
join( )
方法是Java中Thread
类的一个关键实例方法,用于同步线程执行。当一个线程调用另一个线程的join( )
方法时,当前线程将进入等待状态,直到被调用join( )
的线程完成其任务并结束。这在需要确保主线程等待子线程执行完毕之后再继续执行的场景中非常有用。【若在join方法中传入了参数,则是等待调用join()方法的线程执行完毕,或者直到指定的毫秒数之后】
例如,假设主线程创建了一个耗时计算的任务交给子线程执行,并且主线程希望在子线程完成计算后获取结果:
public class JoinExample {
static class LongRunningTask implements Runnable {
@Override
public void run() {
try {
System.out.println("我是子线程,开始执行耗时计算...");
Thread.sleep(2000); // 模拟耗时操作
int result = performComputation(); // 执行计算
System.out.println("我是子线程,计算完成,结果为: " + result);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private int performComputation() {
return 42; // 示例计算结果
}
}
public static void main(String[] args) throws InterruptedException {
Thread longRunning = new Thread(new LongRunningTask());
longRunning.start();
// 主线程等待子线程完成
longRunning.join();
// 子线程结束后,主线程可以安全地访问子线程的结果(此处假设已通过共享变量或其他机制传递)
System.out.println("主线程:子线程已完成,我可以继续执行后续操作了");
}
}
8.5.ThreadLocal类
ThreadLocal
是一个用于创建线程本地变量的工具类。它通过维护一个内部弱引用的Map来管理每个线程的本地变量。这里不详细介绍其原理,如果你对ThreadLocal
类的原理感兴趣,可以先参考:ThreadLocal类原理。
一些朋友称ThreadLocal
为线程本地变量或线程本地存储。严格来说,ThreadLocal
类并不用于多线程间的通信,而是确保每个线程都有自己"独立"的变量,线程之间互不干扰。ThreadLocal
为每个线程创建一个变量副本,每个线程可以访问自己内部的副本变量。
ThreadLocal
类最常用的方法是set
方法和get
方法。以下是一个示例代码:
public class ThreadLocalExample {
// 创建一个ThreadLocal变量
private static ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
// 创建并启动两个线程
Thread thread1 = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
threadLocalValue.set(threadLocalValue.get() + 1);
System.out.println(Thread.currentThread().getName() + ": " + threadLocalValue.get());
}
} finally {
threadLocalValue.remove();
}
});
Thread thread2 = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
threadLocalValue.set(threadLocalValue.get() + 2);
System.out.println(Thread.currentThread().getName() + ": " + threadLocalValue.get());
}
} finally {
threadLocalValue.remove();
}
});
thread1.start();
thread2.start();
}
}
运行结果:
Thread-1: 2
Thread-1: 4
Thread-0: 1
Thread-1: 6
Thread-1: 8
Thread-1: 10
Thread-0: 2
Thread-0: 3
Thread-0: 4
Thread-0: 5
注意事项:
- 独立副本:每个线程都有自己独立的副本变量,线程之间互不干扰。
- 内存泄漏:由于
ThreadLocal
使用的是弱引用,未及时清理的线程副本变量可能会导致内存泄漏,因此在使用完ThreadLocal
变量后,建议调用其remove()
方法清理数据。
8.6.管道通信
Java中的管道通信是通过管道输入流和管道输出流来实现的。这种通信方式主要用于两个线程之间传递数据,管道的一端连接输入流,另一端连接输出流。JDK提供了PipedInputStream
、PipedOutputStream
、PipedReader
、PipedWriter
,前两者实现字节流的管道通信,后两者实现字符流的管道通信。
管道通信的输入流和输出流通过connect()
进行连接,如果没有将输入流和输出流绑定起来,对于该流的访问将会抛出异常【输入流调用方法连接输出流 或者 输出流调用方法连接输入流 任选其一即可】
一个输出流可以绑定到多个输入流,但是一个输入流不能绑定到多个输出流。
应用场景:
管道通信的使用多半和I/O流有关,当我们一个线程需要先获取另一个线程发送的数据(字符串或文件等),就需要使用管道通信了。
// 这里的示例代码使用的是基于字符的:
public class Pipe {
static class ReaderThread implements Runnable {
private PipedReader reader;
public ReaderThread(PipedReader reader) {
this.reader = reader;
}
@Override
public void run() {
System.out.println("this is reader");
int receive = 0;
try {
while ((receive = reader.read()) != -1) {
System.out.print((char)receive);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
static class WriterThread implements Runnable {
private PipedWriter writer;
public WriterThread(PipedWriter writer) {
this.writer = writer;
}
@Override
public void run() {
System.out.println("this is writer");
int receive = 0;
try {
writer.write("test");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws IOException, InterruptedException {
PipedWriter writer = new PipedWriter();
PipedReader reader = new PipedReader();
writer.connect(reader); // 这里注意一定要连接,才能通信
//reader.connect(writer);
new Thread(new ReaderThread(reader)).start();
Thread.sleep(1000);
new Thread(new WriterThread(writer)).start();
}
}
运行结果:
this is reader
this is writer
test