🏆作者简介,普修罗双战士,一直追求不断学习和成长,在技术的道路上持续探索和实践。
🏆多年互联网行业从业经验,历任核心研发工程师,项目技术负责人。
🎉欢迎 👍点赞✍评论⭐收藏
文章目录
- 🔎 一、Java多线程高频知识文集(1)
- 🍁🍁 01. 并行和并发有什么区别?
- 🍁🍁 02. 线程和进程的区别?
- 🍁🍁 03. 守护线程是什么?
- 🍁🍁 04. 线程有哪些状态?
- 🍁🍁 05. 创建线程有哪几种方式?举例说明?
- 🍁🍁 06. 线程池都有哪些状态?
- 🍁🍁 07. 创建线程池有哪几种方式?举例说明?
- 🍁🍁 08. 线程池中 submit() 和 execute() 方法有什么区别?
- 🍁🍁 09. 线程的 run() 和 start() 有什么区别?
- 🍁🍁 10. 在 Java 程序中怎么保证多线程的运行安全?
- 🍁🍁 11. 多线程中 synchronized 锁升级的原理是什么?
- 🍁🍁 12. 说一下 runnable 和 callable 有什么区别?
- 🍁🍁 13. sleep() 和 wait() 有什么区别?
- 🍁🍁 14. notify()和 notifyAll()有什么区别?
- 🍁🍁 15. 什么是死锁?
- 🍁🍁 16. 怎么防止死锁?
- 🍁🍁 17. ThreadLocal 是什么?有哪些使用场景?
- 🍁🍁 18. synchronized 和 volatile 的区别是什么?
- 🍁🍁 19. synchronized 和 Lock 有什么区别?
- 🍁🍁 20. synchronized 和 ReentrantLock 区别是什么?
- 🍁🍁 21. 说一下 atomic 的实现原理?举例说明?
- 🍁🍁 22. 说一下 synchronized 底层实现原理?举例说明?
🔎 一、Java多线程高频知识文集(1)
🍁🍁 01. 并行和并发有什么区别?
并行(Parallel)和并发(Concurrent)是计算机领域中两个重要的概念,它们描述了程序执行和处理任务的不同方式。它们之间的区别在于执行方式及其相关的目标。
并行(Parallel)指的是系统同时执行多个操作或任务的能力。在并行处理中,任务被拆分成多个子任务,并且这些子任务可以同时进行,通过使用多个处理器、多核处理器或者多台计算机,以加快任务的执行速度。并行处理的目标是更快地完成任务,通过同时处理多个任务来提高整体的吞吐量和性能。
并发(Concurrent)指的是系统能够同时管理多个任务的能力,即使这些任务在时间上可能重叠进行。在并发处理中,系统在有限的时间里交替执行多个任务,通过时间片轮转或事件驱动等方式来实现多任务间的交替执行。并发处理的主要目标是提高系统的响应能力、效率和资源利用率,使得多个任务之间可以更好地共享系统资源。
简而言之,对于并行来说,关注的是同时执行多个任务以提高整体性能;而并发则侧重于能够管理多个任务,并提高系统对任务的响应能力及资源利用率。在实际应用中,这两种处理方式经常会同时使用,以便充分利用硬件资源并提供良好的用户体验。
区别 | 并行(Parallel) | 并发(Concurrent) |
---|---|---|
定义 | 多个任务同时执行,通过多个处理单元实现 | 多个任务交替执行,共享资源,提高系统响应和资源利用率 |
目标 | 提高整体性能和执行速度 | 提高系统响应能力、资源利用率和效率 |
执行情况 | 同一时刻多个任务同时执行 | 在有限时间内交替执行多个任务,可能会出现任务重叠 |
示例 | 多核处理器同时执行多个线程,每个核执行一个任务 | 操作系统轮流执行多个进程,通过事件触发处理外部请求 |
使用场景 | 高性能计算、数据处理、图形渲染等 | 多任务操作系统、Web服务器处理请求、并发编程、I/O操作 |
通过这个表格可以更清楚地看出,并行和并发的区别。并行关注的是多个任务同时执行以提高整体性能和执行速度,而并发则关注的是多个任务在有限时间内交替执行,提高系统对任务的响应能力、资源利用率和效率。
🍁🍁 02. 线程和进程的区别?
线程(Thread)和进程(Process)是操作系统中进行任务调度和管理的基本单位,它们之间有以下几点区别:
-
定义:
- 进程:是程序执行时的一个实例,包括代码、数据和进程控制块等资源。每个进程都有独立的内存空间,是操作系统进行资源分配和调度的基本单位。
- 线程:是进程内的一个实体,是进程中的一条执行路径,能够与同一进程中的其他线程共享进程的资源。多个线程共享同一进程的堆和数据段,但拥有独立的栈和寄存器。
-
资源占用:
- 进程:拥有独立的地址空间和资源,每个进程有独立的堆、栈、代码段和数据段。
- 线程:共享所属进程的内存空间和资源,包括代码段、数据段、打开的文件和信号处理等。
-
切换开销:
- 进程:切换开销较大,涉及到页面切换和上下文切换。
- 线程:切换开销较小,线程切换只需保存和恢复少量寄存器内容。
-
创建销毁:
- 进程:创建和撤销进程的开销较大,涉及到资源的分配和释放。
- 线程:创建和销毁线程的开销较小,通常在进程内部进行。
-
协作与通信:
- 进程:不同进程之间的通信需要通过进程间通信(IPC)机制,如管道、消息队列、共享内存等。
- 线程:线程间可以直接读写同一进程的内存空间进行通信,因此线程间的通信更加方便和高效。
总的来说,进程是操作系统资源分配的最小单位,而线程是CPU调度的最小单位。在多线程编程中,多个线程可以协同完成一个任务,线程间的切换开销小,适合处理多个并发任务。而进程更适用于独立运行、互不影响的任务。
区别 | 进程(Process) | 线程(Thread) |
---|---|---|
定义 | 程序执行时的一个实例,拥有独立的地址空间和资源 | 进程内的执行实体,共享所属进程的地址空间和资源 |
资源占用 | 拥有独立的内存空间和资源,包括代码段、数据段、堆栈等 | 共享所属进程的内存空间和资源,包括代码段、数据段、文件等 |
切换开销 | 切换开销较大,涉及到页面切换和上下文切换 | 切换开销较小,只需保存和恢复少量寄存器内容 |
创建销毁 | 创建和销毁的开销较大 | 创建和销毁的开销较小,通常在进程内部进行 |
协作与通信 | 需要通过进程间通信机制进行通信 | 可以直接在同一进程内部进行通信,更加方便和高效 |
通过这个表格,清晰地展现了进程和线程之间的区别。进程具有独立的地址空间和资源,并且切换开销大,适合独立运行的任务;而线程共享所属进程的资源,切换开销小,适合协同完成任务和处理并发操作。
🍁🍁 03. 守护线程是什么?
守护线程(Daemon Thread)是在程序运行时在后台提供服务的线程。它的特点是当所有的非守护线程结束时,守护线程会自动结束,因此它通常被用来执行一些后台任务,如垃圾回收、内存管理等系统级的服务。在Java中,可以通过设置setDaemon(true)
来将一个线程设置为守护线程。
守护线程的典型应用包括在Web服务器中负责监听请求的守护线程、后台定时清理任务的守护线程等。需要注意的是,由于守护线程会在程序的所有非守护线程结束时自动结束,因此在设计守护线程时需要确保其不依赖于非守护线程的执行顺序或状态。
总之,守护线程在后台提供服务,通常用于执行一些系统级的、不需要持续运行的任务,能够在程序的其他任务执行完成后自动结束。
🍁🍁 04. 线程有哪些状态?
在Java多线程编程中,线程可以具有以下几种状态:
-
新建(New):当使用
new Thread()
或者Thread.currentThread().start()
等方式创建了一个新线程但还未开始运行时,该线程处于新建状态。 -
就绪(Runnable):当线程调用了
start()
方法后,该线程进入就绪状态,表示已经准备好运行,等待获取CPU的执行权。 -
运行(Running):当线程获取到CPU的执行权,开始真正执行时,处于运行状态。
-
阻塞(Blocked):当线程被阻塞挂起,等待某个条件的满足或者等待获取某个对象的监视器时,处于阻塞状态。
-
等待(Waiting):当线程需要等待其他线程通知或者等待一定时间时,处于等待状态。
-
超时等待(Timed Waiting):当线程在一定时间内等待其他线程通知或者等待一定时间后,会处于超时等待状态。
-
终止(Terminated):当线程执行完毕、发生异常或者被提前中断时,进入终止状态。
这些是线程在Java多线程编程中常见的状态,线程在不同的状态间转换,将影响其在程序中的行为和执行情况。
🍁🍁 05. 创建线程有哪几种方式?举例说明?
在Java中,创建线程的方式通常有以下几种:
- 继承 Thread 类:通过继承Thread类并重写其run()方法来创建线程。示例代码如下:
public class MyThread extends Thread {public void run() {// 执行线程任务}public static void main(String[] args) {MyThread thread = new MyThread();thread.start();}
}
- 实现 Runnable 接口:定义一个实现了Runnable接口的类,并将其实例作为参数传递给Thread类的构造函数。示例代码如下:
public class MyRunnable implements Runnable {public void run() {// 执行线程任务}public static void main(String[] args) {Thread thread = new Thread(new MyRunnable());thread.start();}
}
- 使用匿名内部类:使用匿名内部类的方式来创建线程。示例代码如下:
public class Main {public static void main(String[] args) {Thread thread = new Thread(new Runnable() {public void run() {// 执行线程任务}});thread.start();}
}
- 使用线程池:通过Executor框架提供的线程池来创建线程。示例代码如下:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class Main {public static void main(String[] args) {ExecutorService executor = Executors.newFixedThreadPool(5);executor.execute(new Runnable() {public void run() {// 执行线程任务}});executor.shutdown();}
}
这些是在Java中常见的创建线程的方式,开发人员可以根据实际情况选择合适的方式来创建线程。
🍁🍁 06. 线程池都有哪些状态?
线程池在 Java 中的状态通常可以分为以下几种:
-
运行(Running):线程池中的线程正在正常运行任务,可以接受新的任务提交。
-
关闭(Shut down):线程池处于关闭状态,不再接受新的任务提交,但会继续执行已经提交的任务。
-
立即关闭(Shut down now):线程池立即停止,尝试中断正在执行的任务,并且不会处理队列中等待执行的任务。
-
终止(Terminated):线程池中的所有任务都已经完成,线程池彻底终止。
这些状态描述了线程池在运行过程中可能出现的不同状态。维护一个良好的线程池状态对于任务的执行和资源的调度非常重要。
🍁🍁 07. 创建线程池有哪几种方式?举例说明?
在Java中,创建线程池的方式通常有以下几种:
-
使用 Executors 工具类:可以使用
Executors
类提供的静态工厂方法来创建线程池,例如:ExecutorService executor = Executors.newFixedThreadPool(5);
上述代码创建了一个固定大小(5个线程)的线程池。
-
使用 ThreadPoolExecutor 类:可以直接使用
ThreadPoolExecutor
类来创建线程池,该类提供了更灵活的配置选项,例如:ThreadPoolExecutor executor = new ThreadPoolExecutor(2, // 核心线程数10, // 最大线程数60, // 线程存活时间TimeUnit.SECONDS, // 存活时间单位new LinkedBlockingQueue<>() // 任务队列 );
上述代码创建了一个核心线程数为2,最大线程数为10的线程池,并使用了一个无界任务队列。
-
自定义线程池:可以根据实际需求,自己实现一个线程池类,对线程池的核心参数和行为进行灵活的定制,例如:
public class MyThreadPoolExecutor extends ThreadPoolExecutor {// 自定义实现逻辑 }MyThreadPoolExecutor executor = new MyThreadPoolExecutor( 2, // 核心线程数10, // 最大线程数60, // 线程存活时间TimeUnit.SECONDS, // 存活时间单位new LinkedBlockingQueue<>() // 任务队列 );
上述代码创建了一个自定义的线程池,继承自ThreadPoolExecutor
,可以根据实际需求来扩展和定制线程池的行为。
这些是在Java中常见的创建线程池的方式,开发人员可以根据实际需求选择最合适的方式来创建线程池。
🍁🍁 08. 线程池中 submit() 和 execute() 方法有什么区别?
在Java中,线程池中的submit()
和execute()
方法都可以用来向线程池提交任务,但它们之间有一些区别:
-
返回值类型:
submit()
方法可以返回一个Future
对象,通过这个Future
对象可以获取任务执行的结果或者取消任务。而execute()
方法没有返回值,无法获取任务执行的结果。 -
异常处理:
submit()
方法可以处理任务执行过程中抛出的异常,通过捕获Future
对象的get()
方法抛出的异常来处理任务执行过程中发生的异常,而execute()
方法无法直接获取任务的执行结果或异常。 -
适用范围:
submit()
方法更灵活,适用于Callable和Runnable类型的任务,而execute()
方法只能接受Runnable类型的任务。
示例代码如下:
使用submit()
方法提交任务:
ExecutorService executor = Executors.newFixedThreadPool(5);
Future<Integer> future = executor.submit(() -> {// 执行任务并返回结果return 42;
});
try {Integer result = future.get();System.out.println("任务执行结果:" + result);
} catch (InterruptedException | ExecutionException e) {System.out.println("任务执行出现异常:" + e.getMessage());
}
使用execute()
方法提交任务:
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.execute(() -> {// 执行任务
});
总的来说,submit()
方法更适合需要获取任务执行结果或处理任务执行异常的情况,而execute()
方法则更适合不需要关心任务执行结果的场景。
下面是使用表格形式总结submit()
方法和execute()
方法的区别:
区别 | 返回值类型 | 异常处理 | 适用范围 |
---|---|---|---|
submit() | Future 对象 | 可以处理异常 | Callable和Runnable任务 |
execute() | 无返回值 | 无法处理异常 | Runnable任务 |
这个表格更清晰地总结了两种方法的区别,包括返回值类型、异常处理以及适用范围的不同点。根据需求选择正确的方法将有助于更好地管理和控制线程池中的任务。
🍁🍁 09. 线程的 run() 和 start() 有什么区别?
在Java中,线程的run()
方法和start()
方法是线程生命周期中的两个重要方法,它们之间有以下区别:
-
调用方式:
run()
方法是线程对象的普通方法,可以直接通过线程对象调用,会在当前线程中执行任务的代码块。start()
方法是线程对象的启动方法,调用后会创建一个新的线程,并从新的线程中执行任务的代码块。
-
多线程执行:
- 使用
run()
方法只会在当前线程中顺序执行任务的代码块,不会创建新的线程进行并发执行。 - 使用
start()
方法会创建一个新的线程,使线程对象的run()
方法在新的线程中并发执行。
- 使用
-
生命周期:
- 使用
run()
方法只会执行任务的代码块,不会涉及线程的生命周期管理,线程对象会保持在"RUNNABLE"状态,直到任务执行完毕。 - 使用
start()
方法启动线程会涉及到线程的完整生命周期管理,包括线程的创建、就绪、运行和终止等。
- 使用
示例代码如下:
使用run()
方法执行任务:
class MyRunnable implements Runnable {@Overridepublic void run() {// 执行任务的代码块System.out.println("任务执行中");}
}public class Main {public static void main(String[] args) {MyRunnable runnable = new MyRunnable();runnable.run(); // 在当前线程中执行任务}
}
使用start()
方法启动线程:
class MyThread extends Thread {@Overridepublic void run() {// 执行任务的代码块System.out.println("任务执行中");}
}public class Main {public static void main(String[] args) {MyThread thread = new MyThread();thread.start(); // 创建新线程并执行任务}
}
总的来说,run()
方法是线程对象的普通方法,会在当前线程中按顺序执行任务的代码块;而start()
方法是启动线程的方法,会创建一个新的线程并执行任务代码块。使用start()
方法可以实现多线程并发执行,利用系统资源进行任务的并行处理。
下面是使用表格形式总结run()
方法和start()
方法的区别:
区别 | 调用方式 | 多线程执行 | 生命周期 |
---|---|---|---|
run() | 线程对象的普通方法 | 在当前线程中执行 | 仅执行任务 |
start() | 线程对象的启动方法 | 创建新线程并执行 | 完整生命周期 |
这个表格更直观地总结了run()
方法和start()
方法的区别,包括调用方式、多线程执行和生命周期的差异。根据需求选择正确的方法可以实现适当的线程控制和并发执行任务。需要注意的是,直接调用run()
方法并不会创建新线程,任务将在当前线程中按顺序执行。只有通过start()
方法才能创建新线程并执行任务代码块。
🍁🍁 10. 在 Java 程序中怎么保证多线程的运行安全?
在 Java 程序中为了保证多线程的运行安全,可以采取以下措施:
-
使用 synchronized 关键字:synchronized 可以修饰方法或者代码块,用于实现对实例或类的加锁,保证同一时间只有一个线程可以访问被锁定的方法或者代码块,防止多个线程同时对共享变量进行访问造成数据竞争。
-
使用 Lock 接口:Lock 接口提供了更加灵活和强大的加锁机制,拥有 tryLock()、lockInterruptibly()、newCondition()等方法,可以满足复杂的多线程并发处理需求。
-
使用原子操作类:Java 提供了 AtomicInteger、AtomicLong、AtomicReference 等原子操作类,这些类提供了一些原子操作方法,可以保证对共享变量的修改操作不会出现线程安全问题。
-
使用线程安全的数据结构:Java 提供了一些线程安全的数据结构,如 ConcurrentHashMap、CopyOnWriteArrayList 等,这些数据结构在多线程读取和写入时都可以保证数据的正确性。
-
避免使用非线程安全的类:例如 SimpleDateFormat、StringBuilder 等,它们在多个线程并发使用时容易出现线程安全问题,需要使用线程安全的替代方案。
当涉及到多线程的运行安全时,以下是一些示例代码:
-
使用 synchronized 关键字:
public class Counter {private int count;public synchronized void increment() {count++;}public synchronized int getCount() {return count;} }
在上述代码中,使用 synchronized 关键字修饰了 increment()
和 getCount()
方法,这样就保证了在同一时间内只有一个线程可以执行这两个方法,避免了多线程并发修改 count 变量导致的数据竞争问题。
-
使用 Lock 接口:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock;public class Counter {private int count;private Lock lock = new ReentrantLock();public void increment() {lock.lock();try {count++;} finally {lock.unlock();}}public int getCount() {lock.lock();try {return count;} finally {lock.unlock();}} }
在上述代码中,使用 Lock 接口和 ReentrantLock 类创建了一个锁对象 lock,通过 lock()
和 unlock()
方法手动加锁和解锁,确保在对 count 进行操作时只有一个线程能够获得锁。
-
使用原子操作类:
import java.util.concurrent.atomic.AtomicInteger;public class Counter {private AtomicInteger count = new AtomicInteger();public void increment() {count.incrementAndGet();}public int getCount() {return count.get();} }
在上述代码中,使用 AtomicInteger 类作为计数器,incrementAndGet()
方法能够原子地增加计数器的值,避免了多线程并发修改导致的数据不一致问题。
这些示例代码展示了不同的方法来确保多线程运行安全,具体选择哪种方法取决于你的需求和场景。
🍁🍁 11. 多线程中 synchronized 锁升级的原理是什么?
在 Java 中,synchronized 是一种独占锁(也称为互斥锁)。它的原理是基于对象头中的 Mark Word(标记字),在锁升级的过程中,锁的状态会从无锁状态(无锁标记)逐渐升级为偏向锁、轻量级锁和重量级锁。
下面是 synchronized 锁升级的原理:
-
无锁状态:对象初始状态下为无锁状态,没有加锁的标记。当一个线程尝试获取对象的锁时,会将对象头中的 Mark Word 设置为锁的标记,然后进入偏向锁模式。
-
偏向锁状态:当一个线程获取到对象的偏向锁时,会将对象头中的线程 ID 记录在 Mark Word 中,然后该线程可以通过 CAS(比较并交换)操作来很快地获取锁和释放锁。只有当其他线程尝试获取锁时,偏向锁会升级为轻量级锁。
-
轻量级锁状态:如果一个线程在获取轻量级锁时,发现该锁已经被其他线程占用,它会将自己的线程 ID 记录在对象头中,并将对象的内容复制到自己的线程栈帧中,然后使用 CAS 操作尝试更新对象头中的 Mark Word 为指向自己线程的锁记录。如果 CAS 成功,表示该线程获得了轻量级锁;如果 CAS 失败,表示由于竞争,锁会升级为重量级锁。
-
重量级锁状态:当锁升级到重量级锁时,它会使用互斥量(Mutex)来实现线程阻塞和唤醒,同时涉及到操作系统级别的线程切换。
需要注意的是,锁的升级过程是逐步的,不会一开始就直接升级到重量级锁,这样可以减少锁带来的性能开销。锁升级的过程是通过对象头中的状态位来进行判断和升级的。
锁升级的目的是为了提高多线程并发处理的效率,根据实际的并发情况,锁会根据线程竞争情况适时升级,以提供更好的性能和适应不同的并发场景。
🍁🍁 12. 说一下 runnable 和 callable 有什么区别?
Runnable 和 Callable 是 Java 多线程编程中用于创建可并发执行任务的两个接口,它们具有以下区别:
-
返回值:Runnable 接口的
run()
方法不返回任何结果,它是一个 void 方法,而 Callable 接口的call()
方法返回一个结果。 -
异常处理:Runnable 接口的
run()
方法不允许抛出任何受检查异常,即方法签名中没有声明异常。而 Callable 接口的call()
方法可以抛出受检查异常。 -
使用方式:Runnable 接口通常与 Thread 类一起使用,通过创建一个实现了 Runnable 接口的类的实例,并将其传递给 Thread 构造函数来创建一个线程。例如:
Thread thread = new Thread(runnable);
。Callable 接口通常与 ExecutorService 结合使用,通过提交 Callable 接口的实例到线程池中执行,然后通过 Future 对象获取返回结果。例如:Future<T> future = executorService.submit(callable);
-
返回结果的获取:Runnable 接口没有返回结果,因此无法直接获取执行结果。而 Callable 接口的
call()
方法执行完毕后会返回一个结果,可以通过 Future 对象的get()
方法获取。 -
泛型类型:Callable 接口是一个泛型接口,通过泛型类型参数指定返回结果的类型。例如:
Callable<Integer> callable = new MyCallable();
。
总之,Runnable 接口适用于需要在后台执行任务而无需返回结果的场景,Callable 接口适用于需要执行任务并返回结果的场景。 Callable 接口相比于 Runnable 接口更灵活,但也需要更多的代码来处理返回值和异常。
对比项 | Runnable | Callable |
---|---|---|
返回值 | void | 返回一个结果 |
异常处理 | 不允许抛出受检查异常 | 可以抛出受检查异常 |
使用方式 | 通常与 Thread 类一起使用 | 通常与 ExecutorService 结合使用 |
返回结果的获取 | 无法直接获取执行结果 | 可以通过 Future 对象获取执行结果 |
泛型类型 | 无泛型类型参数 | 通过泛型类型参数指定返回结果的类型 |
接口的区别主要体现在返回值,异常处理,使用方式,返回结果获取和泛型类型上。Runnable 接口没有返回值和受检查异常,通常与 Thread 类一起使用,无法直接获取执行结果。Callable 接口需要指定返回值类型和可以抛出受检查异常,通常与 ExecutorService 结合使用,可以通过 Future 对象获取执行结果。
例如,当需要在新的线程中执行一个任务时,可以使用 Runnable 接口来实现这个任务:
public class MyRunnable implements Runnable {@Overridepublic void run() {// 执行任务}
}// 创建一个新的线程并启动它
Thread thread = new Thread(new MyRunnable());
thread.start();
当需要执行某个任务并返回结果时,可以使用 Callable 接口来实现:
public class MyCallable implements Callable<Integer> {@Overridepublic Integer call() throws Exception {// 执行任务并返回结果return 42;}
}// 提交到线程池中执行,并获取执行结果
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<Integer> future = executorService.submit(new MyCallable());
Integer result = future.get();
System.out.println(result); // 输出 42
🍁🍁 13. sleep() 和 wait() 有什么区别?
sleep() 和 wait() 都是 Java 多线程编程中用于挂起线程的方法,它们的区别如下:
-
来源: sleep() 是 Thread 类的静态方法,可以通过 Thread.sleep() 来调用。wait() 是 Object 类的实例方法,只能在同步块或同步方法中调用。
-
对象锁: sleep() 不会释放对象锁,线程休眠结束后,线程会自动恢复运行状态并重新竞争对象锁。而 wait() 会释放对象锁,使调用 wait() 方法的线程进入对象的等待池中,只有当其他线程调用对象的 notify() 或 notifyAll() 方法,或者超时停止等待,才能重新竞争对象锁。
-
调用方式: sleep() 方法调用时,线程不会释放已持有的锁,因此其他线程无法访问持有该锁的对象。而 wait() 方法可以释放当前线程持有的锁,使其他线程可以访问持有该锁的对象。
-
时间单位: sleep() 方法的参数表示线程休眠的时间,单位是毫秒,精确到纳秒。而 wait() 方法的参数表示等待的时间,单位也是毫秒,精确到毫秒。
一般而言,sleep() 的主要作用是休眠一段时间,wait() 的主要作用是在同步场景中等待某个条件的满足。如果在 synchronized 块中需要等待一些条件,则需要使用 wait() 方法。而如果只需要让当前线程休眠一段时间,则需要使用 sleep() 方法。
下面是一个使用 wait() 和 notify() 让线程等待和唤醒的例子:
class MyThread implements Runnable {private Object lock;public MyThread(Object lock) {this.lock = lock;}@Overridepublic void run() {synchronized (lock) {try {System.out.println(Thread.currentThread().getName() + " waiting");lock.wait();System.out.println(Thread.currentThread().getName() + " resumed");} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Main {public static void main(String[] args) throws InterruptedException {Object lock = new Object();MyThread thread = new MyThread(lock);Thread t1 = new Thread(thread, "Thread-1");Thread t2 = new Thread(thread, "Thread-2");t1.start();t2.start();Thread.sleep(2000); // 休眠 2 秒钟synchronized (lock) {lock.notifyAll(); // 唤醒所有等待该对象锁的线程}}
}
以上代码中,我们创建了2个线程 t1 和 t2,每个线程都通过wait()方法进入了锁对象 lock 的等待池,接下来我们等待2秒之后,通过notifyAll()方法唤醒所有等待该对象锁的线程,然后线程 t1 和 t2 分别重新开始了运行。注意,唤醒线程时,需要在同步块中进行,否则会抛出 IllegalMonitorStateException 异常。
下面是一个更直观的表格,说明了 sleep() 和 wait() 的区别:
特点 | sleep() | wait() |
---|---|---|
来源 | Thread 类的静态方法 | Object 类的实例方法 |
对象锁 | 不释放对象锁 | 释放对象锁(进入等待池) |
调用方式 | 可以在任何地方调用 | 在同步块或同步方法中调用 |
时间单位 | 毫秒或纳秒 | 毫秒 |
指定时间后继续执行 | 是 | 是 |
需要通知或等待条件 | 无 | 需要 |
通过这个表格,可以清晰地看出两者之间的区别。sleep() 方法主要用于让线程休眠一段时间,并不释放对象锁,而 wait() 方法主要用于在同步场景中等待某个条件的满足,会释放对象锁,进入等待池。
🍁🍁 14. notify()和 notifyAll()有什么区别?
notify() 和 notifyAll() 都是用于唤醒处于等待状态的线程,并让它们重新进入就绪状态,区别如下:
-
唤醒线程数量:notify() 方法只会唤醒等待队列中的一个线程,选择唤醒哪个线程是不确定的,而 notifyAll() 方法会唤醒等待队列中的所有线程,让它们都有机会竞争获取锁。
-
锁状态:notify() 方法在执行完毕后,唤醒的线程会进入锁的竞争状态,如果没有得到锁,仍然会继续等待。而 notifyAll() 方法执行完毕后,唤醒的线程们会进入锁的竞争状态,如果没有得到锁,仍然会继续等待。
-
使用场景:一般来说,如果只有一个线程需要被唤醒,则可以使用 notify() 方法;如果有多个线程需要被唤醒,则通常使用 notifyAll() 方法。
下面是一个示例代码,演示了 notify() 和 notifyAll() 方法的使用:
class MyThread implements Runnable {private Object lock;public MyThread(Object lock) {this.lock = lock;}@Overridepublic void run() {synchronized (lock) {try {System.out.println("Thread " + Thread.currentThread().getName() + " waiting");lock.wait();System.out.println("Thread " + Thread.currentThread().getName() + " resumed");} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Main {public static void main(String[] args) throws InterruptedException {Object lock = new Object();MyThread thread1 = new MyThread(lock);MyThread thread2 = new MyThread(lock);Thread t1 = new Thread(thread1, "Thread-1");Thread t2 = new Thread(thread2, "Thread-2");t1.start();t2.start();Thread.sleep(2000); // 休眠2秒钟synchronized (lock) {lock.notify(); // 唤醒等待该对象锁的一个线程// 或者使用 lock.notifyAll(); 唤醒等待该对象锁的所有线程}}
}
以上代码中,我们使用了两个线程 t1 和 t2,它们在同一个对象 lock 上进行同步,并在同步块中调用了 lock.wait() 方法,进入等待状态。然后我们通过 lock.notify() 方法唤醒了一个等待中的线程,或者使用 lock.notifyAll() 方法唤醒了所有等待中的线程。线程被唤醒后,会继续执行后续的代码。
下面是一个更直观的表格,说明了 notify() 和 notifyAll() 的区别:
特点 | notify() | notifyAll() |
---|---|---|
唤醒线程数 | 唤醒一个线程 | 唤醒所有线程 |
竞争锁 | 竞争唤醒后的锁 | 竞争唤醒后的锁 |
使用场景 | 多个线程等待同一个锁 | 多个线程等待同一个锁 |
唤醒线程的方式 | 不确定 | 公平/非公平 |
通过这个表格,可以清晰地看出 notify() 和 notifyAll() 之间的区别。选择使用哪一个方法,要根据具体的使用场景和需要唤醒的线程数量来进行决策。当需要唤醒所有等待线程时,可以使用 notifyAll() 方法。否则,如果只需要唤醒其中一个线程即可,可以使用 notify() 方法。
🍁🍁 15. 什么是死锁?
死锁是指在多线程并发环境中,两个或多个线程互相持有对方所需的资源,并且由于线程都在等待对方释放资源而无法继续执行的一种状态。简而言之,死锁是线程之间因为相互等待对方所拥有的资源而陷入无法前进的僵持状态。
死锁发生的四个必要条件,也被称为死锁的四个条件:
-
互斥条件(Mutual Exclusion):对于某种资源,每次只能被一个线程占用,若其他线程申请该资源,则必须等待。
-
请求与保持条件(Hold and Wait):一个线程在申请资源时,已经占有了其他的资源,并且在等待新申请的资源。
-
不可剥夺条件(No Preemption):已分配给一个线程的资源不能被强制剥夺,只能由线程自己释放。
-
循环等待条件(Circular Wait):若干线程形成一种循环等待资源的关系,即每个线程都在等待下一个线程所持有的资源。
当满足以上四个条件时,死锁就有可能发生。当死锁发生时,线程将无法继续执行,程序可能会停止响应或崩溃。
为了避免死锁的发生,可以采取以下几种常见的措施:
-
避免一个线程同时持有多个锁。
-
避免线程在获取锁的同时还需要请求其他的锁。
-
使用定时锁,即尝试获取锁一段时间后未成功,就放弃并释放已经获取的锁。
-
设置线程等待的超时时间,当等待超过一定时间后,就放弃请求该资源。
-
尽量使用无锁编程,如使用并发容器(ConcurrentHashMap、ConcurrentLinkedQueue)代替传统的同步容器。
-
合理规划资源分配,避免循环等待的产生。
检测和解决死锁问题是一项复杂且重要的任务,需要仔细分析代码和线程间的资源竞争情况,以及合理设计和管理资源的使用。
以下是两个简单的例子,展示了可能导致死锁的情况:
-
同步的嵌套调用:
class Deadlock {public synchronized void method1() {System.out.println("在 method1 中");method2();}public synchronized void method2() {System.out.println("在 method2 中");method1();} }public class DeadlockExample {public static void main(String[] args) {Deadlock deadlock = new Deadlock();// 创建并启动两个线程new Thread(() -> {deadlock.method1();}).start();new Thread(() -> {deadlock.method2();}).start();} }
在上述代码中,两个线程分别调用 method1()
和 method2()
方法,并且这两个方法都是同步的。当一个线程进入 method1()
方法时,它会获得锁,并尝试调用 method2()
,但是这个方法在另一个线程中也需要获得锁才能执行。同样地,当一个线程进入 method2()
方法时,它会获得锁,并尝试调用 method1()
,但是这个方法在另一个线程中也需要获得锁才能执行。因此,这两个线程可能会在相互等待对方释放锁的情况下陷入死锁。
-
资源竞争导致的死锁:
class Deadlock {private final Object resource1 = new Object();private final Object resource2 = new Object();public void method1() {synchronized (resource1) {System.out.println("在 method1 中获取 resource1 锁");synchronized (resource2) {System.out.println("在 method1 中获取 resource2 锁");// 执行任务...}}}public void method2() {synchronized (resource2) {System.out.println("在 method2 中获取 resource2 锁");synchronized (resource1) {System.out.println("在 method2 中获取 resource1 锁");// 执行任务...}}} }public class DeadlockExample {public static void main(String[] args) {Deadlock deadlock = new Deadlock();// 创建并启动两个线程new Thread(() -> {deadlock.method1();}).start();new Thread(() -> {deadlock.method2();}).start();} }
在上述代码中,两个线程分别调用 method1()
和 method2()
方法,并且这两个方法都涉及到了资源竞争。线程1首先获得 resource1
锁,然后尝试获取 resource2
锁;而线程2首先获得 resource2
锁,然后尝试获取 resource1
锁。当这两个线程交错执行时,如果它们同时尝试获取对方持有的资源锁,则可能会发生死锁。
这些例子只是简单地展示了可能会导致死锁的情况,实际的应用中可能会更加复杂。在开发过程中,需要仔细分析并设计代码,以避免死锁的发生。
🍁🍁 16. 怎么防止死锁?
以下是一些可以避免死锁的方法:
-
避免使用多个锁:如果每个线程只使用一个锁,那么就不会发生死锁。尽量将共享资源的竞争锁降到最小。
-
交叉锁:在保证程序正确性的前提下,尝试交叉上锁顺序,降低死锁的发生概率。
-
超时机制:允许等待一定时间以后仍然没有获取到锁的线程放弃获取锁,以减少死锁的概率。可以使用
tryLock()
方法,该方法可以设置等待时间,如果在规定时间内未能获得锁,则返回false
。 -
统一锁顺序:假设一个任务总是按照特定的顺序请求锁,那么就可以避免死锁。例如,任务 A 总是先请求资源 1 的锁,然后再请求资源 2 的锁;任务 B 总是先请求资源 2 的锁,然后再请求资源 1 的锁。
-
避免持有锁的时间过长:锁定的时间越长,发生死锁的概率就越大。一些方法可以用来减少锁定时间,例如,通过使用无锁的数据结构,或者使用更加高效的锁。
-
死锁检测:一些操作系统和编程语言提供了死锁检测功能。如果检测到死锁事件,系统会自动终止其中一个或多个线程,从而解决死锁问题。
需要注意的是,并不是所有的应用都可以使用上述方法避免死锁,因此需要根据具体情况选择最佳的方法。所有的防止死锁的方法在设计和实现时都需要谨慎,并经过充分测试。
🍁🍁 17. ThreadLocal 是什么?有哪些使用场景?
ThreadLocal 是一个线程绑定的变量,它提供了线程局部变量的功能。每个 ThreadLocal 对象都可以存储一个线程独有的变量副本,并且每个线程都只能访问自己的变量副本,而不会被其他线程共享。
ThreadLocal 的使用场景主要有以下几种:
-
线程上下文信息传递:在多线程环境下,有时需要在线程之间传递上下文信息,例如用户身份认证信息、语言环境等。通过 ThreadLocal 可以方便地在不同线程之间传递这些信息,每个线程都可以独立地访问和修改自己的上下文信息。
-
每个线程独立的实例副本:有些情况下,每个线程需要独立地拥有一个对象的实例副本,以保证线程安全。使用 ThreadLocal 可以在每个线程中创建一个独立的对象副本,而不需要使用同步机制。
-
数据库连接管理:在数据库访问过程中,通常会使用连接池来管理数据库连接。使用 ThreadLocal 可以确保每个线程都能获取到自己独立的数据库连接,避免了线程之间的连接竞争和线程安全问题。
需要注意的是,尽管 ThreadLocal 可以提供线程间的数据隔离,但同时也会增加内存的开销。在使用 ThreadLocal 时,应该保证及时清理不再使用的变量副本,以避免内存泄漏问题。另外,由于 ThreadLocal 是线程私有的,因此在多线程环境中使用时要注意变量的可见性和线程安全性。
🍁🍁 18. synchronized 和 volatile 的区别是什么?
synchronized 和 volatile 都是 Java 中用来保证多线程编程中内存可见性和线程安全的关键字,它们的区别在于:
-
作用范围:synchronized 作用于代码块或方法,而 volatile 只能作用于实例变量和类变量。
-
实现方式: synchronized 通过线程的阻塞/唤醒来实现对共享资源的访问控制,而 volatile 通过禁止指令重排序和在缓存和主内存之间保持一致性来实现内存可见性。
-
原子性: synchronized 能够保证代码块或方法的原子性,而 volatile 虽然能够保证对变量的读写操作的原子性,但是不能保证复合操作的原子性。
-
适用场景: synchronized 适用于控制对共享资源的多线程访问,而 volatile 适用于只有一个线程修改变量值,而其他线程只读取变量值的情况。
需要注意的是,虽然 volatile 能够保证内存可见性,但是不能保证线程安全。如果需要保证完整操作的原子性,应该使用其他方法,例如 synchronized 关键字或使用 AtomicInteger 等类。
下面是一个简单的表格,说明了 synchronized 和 volatile 的区别:
synchronized | volatile | |
---|---|---|
作用范围 | 代码块或方法 | 实例变量或类变量 |
实现方式 | 阻塞/唤醒机制 | 禁止指令重排序、保持缓存和主内存一致性 |
原子性 | 可保证代码块或方法的原子性 | 可保证对变量的读写的原子性,但不能保证复合操作的原子性 |
适用场景 | 控制对共享资源的多线程访问 | 一个线程写入变量值,其他线程只读取变量值 |
线程安全性 | 可以确保线程安全 | 不能保证线程安全 |
这个表格总结了 synchronized 和 volatile 的主要区别。需要根据具体的场景和需求来选择合适的关键字来保证多线程编程的内存可见性和线程安全性。
🍁🍁 19. synchronized 和 Lock 有什么区别?
synchronized 和 Lock 都是 Java 中用来保证多线程编程中内存可见性和线程安全的关键字/类库,它们的主要区别在于以下几点:
-
锁的可重入性:synchronized 是可重入锁,也就是说,如果一个线程已经持有了某个对象的锁,那么它可以再次获取该对象的锁而不会出现死锁。而 Lock 也是可重入锁,但需要显式地调用 lock() 和 unlock() 方法来获取和释放锁。
-
锁的获取方式:synchronized 是非公平锁,也就是说,多个线程同时请求一把锁,那么锁的获取是随机的,没有先后顺序。而 Lock 可以是公平锁或非公平锁,对于公平锁,多个线程请求锁时会按照请求的先后顺序获取锁。
-
锁的中断响应:synchronized 无法响应锁的中断,而 Lock 可以通过 lockInterruptibly() 方法响应锁的中断请求。
-
锁的可见性:synchronized 在释放锁的同时会将修改的内容立即刷新到主内存中,而 Lock 可以由开发者控制修改的内容何时刷新到主内存中,增加了灵活性。
-
API 的丰富性:Lock 提供了比 synchronized 更加丰富的 API,例如可以控制锁超时、多路锁操作等。
需要注意的是,使用 Lock 可以提供更加细粒度的控制,但同时也增加了代码的复杂度和风险。在实际使用中,应该根据具体的场景和需求来选择合适的同步机制来保证多线程编程的内存可见性和线程安全性。
下面是一个简单的表格,说明了 synchronized 和 Lock 的区别:
synchronized | Lock | |
---|---|---|
锁的可重入性 | 可重入 | 可重入 |
锁的获取方式 | 非公平锁 | 可以是公平锁或非公平锁 |
锁的中断响应 | 无法响应中断 | 可以响应中断 |
锁的可见性 | 自动刷新修改的内容到主内存 | 由开发者控制刷新修改的内容到主内存 |
API 的丰富性 | API 相对简单 | 提供了更多的 API |
这个表格总结了 synchronized 和 Lock 的主要区别。需要根据具体的场景和需求来选择合适的同步机制来保证多线程编程的内存可见性和线程安全性。
🍁🍁 20. synchronized 和 ReentrantLock 区别是什么?
synchronized 和 ReentrantLock 都是 Java 中用来保证多线程编程中内存可见性和线程安全的关键字/类库,它们的主要区别在于以下几点:
-
锁的可重入性:synchronized 是可重入锁,也就是说,如果一个线程已经持有了某个对象的锁,那么它可以再次获取该对象的锁而不会出现死锁。而 ReentrantLock 也是可重入锁,但需要显式地调用 lock() 和 unlock() 方法来获取和释放锁。
-
锁的获取方式:synchronized 是非公平锁,也就是说,多个线程同时请求一把锁,那么锁的获取是随机的,没有先后顺序。而 ReentrantLock 可以是公平锁或非公平锁,对于公平锁,多个线程请求锁时会按照请求的先后顺序获取锁。
-
锁的中断响应:synchronized 无法响应锁的中断,而 ReentrantLock 可以通过 lockInterruptibly() 方法响应锁的中断请求。
-
锁的可见性:synchronized 在释放锁的同时会将修改的内容立即刷新到主内存中,而 ReentrantLock 可以由开发者控制修改的内容何时刷新到主内存中,增加了灵活性。
-
API 的丰富性:ReentrantLock 提供了比 synchronized 更加丰富的 API,例如可以控制锁超时、多路锁操作等。
需要注意的是,使用 ReentrantLock 可以提供更加细粒度的控制,但同时也增加了代码的复杂度和风险。在实际使用中,应该根据具体的场景和需求来选择合适的同步机制来保证多线程编程的内存可见性和线程安全性。
下面是一个简单的表格,说明了 synchronized 和 ReentrantLock 的区别:
synchronized | ReentrantLock | |
---|---|---|
锁的可重入性 | 可重入 | 可重入 |
锁的获取方式 | 非公平锁 | 可以是公平锁或非公平锁 |
锁的中断响应 | 无法响应中断 | 可以响应中断 |
锁的可见性 | 自动刷新修改的内容到主内存 | 由开发者控制刷新修改的内容到主内存 |
API 的丰富性 | API 相对简单 | 提供了更多的 API |
这个表格总结了 synchronized 和 ReentrantLock 的主要区别。需要根据具体的场景和需求来选择合适的同步机制来保证多线程编程的内存可见性和线程安全性。
🍁🍁 21. 说一下 atomic 的实现原理?举例说明?
atomic 是 Java 中用来保证多线程并发访问下的内存可见性和数据同步的解决方案,它的实现原理主要基于 CAS (Compare-And-Swap) 操作。CAS 是一种基于乐观思想的算法,它通过比较内存中的值和期望的值是否相等来决定是否进行后续操作。如果相等,则执行操作并修改值,否则什么都不做。
具体的实现方式是,利用了 CPU 指令级别的原子性操作,比如 x86 架构中的 cmpxchg 指令就是一种 CAS 操作,可以通过使用锁总线的方式保证原子性操作。除了锁总线,还可以通过无锁算法实现,例如使用类似于自旋锁的方式在多次尝试中执行 CAS 操作。atomic 类的实现就是基于这些底层机制来保证线程安全。
例如,对于 AtomicInteger 类,它通过内部的 value 属性实现对一个整数的原子操作,比如加减操作。在执行加操作时,会首先读取 value 的值,然后执行 CAS 操作,将期望的值加上指定的数,并更新 value 的值。如果 CAS 操作失败,则重新读取 value 的值,再尝试执行 CAS 操作,直到成功为止。这样可以保证在并发访问下,多个线程对 value 进行原子操作而不会出现问题。
🍁🍁 22. 说一下 synchronized 底层实现原理?举例说明?
synchronized 是 Java 中用来保证多线程并发访问下的内存可见性和数据同步的关键字,它的底层实现涉及到了对象头、Monitor(监视器)和线程的协作。
具体的实现原理如下:
-
对象头:每个 Java 对象在内存中都有一个对象头,在对象头中包含了与同步相关的信息,例如锁的状态、持有锁的线程信息等。
-
Monitor:Monitor 是一个对象的内部数据结构,用来管理对象的同步状态。每个 Java 对象都与一个 Monitor 相关联,它包含了一些用来实现线程同步的数据结构,如等待队列和持有锁的线程队列等。
-
锁的获取和释放:当一个线程进入一个 synchronized 代码块或方法时,会尝试获取对象的 Monitor。如果 Monitor 的锁状态为无锁状态(也称为偏向锁状态),则尝试使用 CAS 操作将锁状态设置为锁定,并将持有锁的线程设为当前线程;如果锁状态已经为锁定状态,且持有锁的线程为当前线程,则增加锁的计数器,表示重入次数增加;如果锁状态已经为锁定状态,且持有锁的线程不是当前线程,则将当前线程加入到等待队列中,线程会进入阻塞状态。当一个线程退出 synchronized 代码块或方法时,会释放 Monitor,将锁的状态设置为无锁状态,并且唤醒等待队列中的一个线程。
举个例子,假设有一个共享变量 count 和两个线程 A 和 B,它们同时执行以下代码:
synchronized (this) {count++; }
当线程 A 进入 synchronized 代码块时,它会通过获取 this 对象的 Monitor 来保证互斥访问。如果此时锁的状态是无锁状态,则线程 A 获取到锁,并将锁状态设置为锁定状态;如果锁的状态是锁定状态且持有锁的线程是线程 A,则线程 A 增加锁的计数器。在操作完成后,线程 A 释放锁并唤醒等待队列中的线程。
总结来说,synchronized 的底层实现原理是通过对象头、Monitor 和线程的协作来实现对临界区的互斥访问和线程的同步。通过对对象的锁状态进行判断和操作,保证了多线程之间对共享资源的安全访问。
继续解释 synchronized 底层实现原理:
-
激活和等待线程:当一个线程无法获取到对象的 Monitor 并被阻塞时,它会被放入等待队列中。当持有锁的线程释放锁并唤醒等待队列中的线程时,被唤醒的线程会重新尝试获取锁。这个过程是通过线程的状态切换和操作系统的线程调度来完成的。
-
互斥性和可见性:synchronized 关键字保证了临界区的互斥性,即同一时刻只有一个线程可以执行 synchronized 代码块或方法。它还保证了对共享变量的修改对其他线程的可见性,即一个线程对共享变量的修改在释放锁之后对其他线程可见。
下面是一个示例,展示了 synchronized 的使用:
public class Counter {private int count = 0;public synchronized void increment() {count++;}
}
在这个示例中,increment() 方法使用 synchronized 关键字修饰,表示该方法是一个同步方法。当多个线程同时调用 increment() 方法时,只有一个线程可以进入该方法并对 count 变量进行递增操作,确保了数据的正确性和一致性。在获取对象锁和释放对象锁的过程中,实现了线程之间的互斥和同步。
需要注意的是,synchronized 既可以修饰代码块,也可以修饰方法。在修饰代码块时,可以使用不同的对象作为锁来实现更细粒度的同步。此外,synchronized 还支持重入锁的机制,允许同一个线程多次获取同一个对象的锁,避免了死锁的发生。
总而言之,synchronized 通过对象的 Monitor、互斥锁和等待队列的机制,实现了对临界区的互斥访问和线程的同步,确保了多线程环境下的数据安全和可见性。