线程
1.程序、进程、线程
- 程序:一组指令的合集,一段静态的代码
- 进程:程序的一次执行过程,或正在运行的一个程序
- 线程:线程是进程中的一个执行单元,一个进程可以有多个线程。线程共享进程的资源,如内存和文件句柄。
2.串行、并发、并行
- 串行:一个线程执行完再执行下一个线程
- 并行:多个CPU同时执行多个任务
- 并发:一个CPU同时执行多个任务,交替执行,抢占CPU时间
3.多线程
3.1 多线程的创建
1.继承Thread类
- 定义子类继承Thread类。
- 子类中重写Thread类中的run方法。
- 创建Thread子类对象,即创建了线程对象。
- 调用线程对象start方法:启动线程,调用run方法。
优点:简单易用,适合简单的线程任务。
缺点:由于Java只支持单继承,因此如果已经继承了其他类,则无法使用该方式创建线程。
2.实现Runnable接口
- 创建一个实现了Runnable接口的类
- 实现类去实现Runnable中的抽象方法:run()
- 创建实现类的对象
- 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
- 通过Thread类的对象调用start()
优点:可以避免单继承的限制,适合多个线程共享一个资源的情况。
缺点:需要额外的类来实现Runnable接口,编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
3.实现Callable接口
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。注释:FutureTask是一个包装器,它通过接受Callable来创建,它同时实现了Future和Runnable接口。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
优点:可以返回线程执行的结果,适合需要获取线程执行结果的情况。
缺点:相比前两种方式,稍微复杂一些。
3.2 线程的生命周期
1.新建状态(New):新建一个线程对象。
2.就绪/可运行状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线3.程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
4.运行状态(Running):就绪状态的线程获得CPU并执行程序代码。
5.阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
- 等待阻塞:运行的线程执行wait方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)
- 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
- 其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep的状态超时、join等待线程终止或者超时、以及I/O处理完毕时,线程重新转入就绪状态。
6.死亡状态(Dead):线程执行完成或者因异常退出run方法,该线程结束生命周期。
3.3. 线程状态及各个状态之间的转变原理
线程状态:
1.新建(New):
意义:线程刚刚被创建但尚未开始执行。在这个状态下,线程对象已经被创建,但还没有调用其start()方法来启动线程执行。线程此时不会占用系统资源,只是处于等待被启动的状态。
2.就绪(Runnable):
意义:线程是可工作的。又可以分成正在工作中和即将开始工作,但是没有明确的区分,两种情况下都称之为Runnable。
3.阻塞(Blocked):
意义:线程被阻塞,暂时无法继续执行。这可能是因为线程在等待某个资源(如锁、IO操作、等待其他线程的通知等)而被挂起,无法继续执行任务。
4.等待(Waiting):
意义:线程进入等待状态,等待特定条件的满足。线程会等待,直到其他线程显式地唤醒它或者等待
的条件变为真。
5.超时等待(Timed Waiting):
意义:线程进入有限时间的等待状态。与等待状态类似,但可以设置一个超时时间,线程会等待一段时间后自动唤醒。
6.终止(Terminated):
意义:线程执行完其任务,或者出现异常导致线程提前结束,进入终止状态。
转变原理:
- NEW 到 RUNNABLE 状态:Java 刚创建出来的 Thread 对象就是 NEW 状态,不会被操作系统调度执行。从 NEW 状态转变到 RUNNABLE 状态调用线程对象的 start() 方法就可以了。
- RUNNABLE 与 BLOCKED 的状态转变
- synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,等待的线程会从 RUNNABLE 转变到 BLOCKED 状态。
- 当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转变到 RUNNABLE 状态。
在操作系统层面,线程是会转变到休眠状态的,但是在 JVM 层面,Java 线程的状态不会发生变化,即 Java 线程的状态会保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面处于可执行状态)与等待 I/O(操作系统层面处于休眠状态)没有区别,都是在等待某个资源,都归入了 RUNNABLE 状态。 - RUNNABLE 与 WAITING 的状态转变
- 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法,状态会从 RUNNABLE 转变到 WAITING;调用 Object.notify()、Object.notifyAll() 方法,线程可能从 WAITING 转变到 RUNNABLE 状态。
- 调用无参数的 Thread.join() 方法。join() 是一种线程同步方法,如有一线程对象 Thread t,当调用 t.join() 的时候,执行代码的线程的状态会从 RUNNABLE 转变到 WAITING,等待 thread t 执行完。当线程 t 执行完,等待它的线程会从 WAITING 状态转变到 RUNNABLE 状态。
- 调用 LockSupport.park() 方法,线程的状态会从 RUNNABLE 转变到 WAITING;调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 转变为 RUNNABLE 状态。
- RUNNABLE 与 TIMED_WAITING 的状态转变
- Thread.sleep(long millis)
- Object.wait(long timeout)
- Thread.join(long millis)
- LockSupport.parkNanos(Object blocker, long deadline)
- LockSupport.parkUntil(long deadline)
TIMED_WAITING 和 WAITING 状态的区别,仅仅是调用的是超时参数的方法。
- RUNNABLE 到 TERMINATED 状态
- 线程执行完 run() 方法后,会自动转变到 TERMINATED 状态
- 执行 run() 方法时异常抛出,也会导致线程终止
3.4 Wait(1000)和Sleep(1000)的区别
1、sleep是Thread的静态方法,wait是Object的方法,任何对象实例都能调用。
2、sleep不会释放锁,它也不需要占用锁。wait会释放锁,但调用它的前提是当前线程占有锁(即代码要在synchronized中)。
3、它们都可以被interrupted方法中断。
Thread.Sleep(1000) 意思是在未来的1000毫秒内本线程不参与CPU竞争,1000毫秒过去之后,这时候也许另外一个线程正在使用CPU,那么这时候操作系统是不会重新分配CPU的,直到那个线程挂起或结束,即使这个时候恰巧轮到操作系统进行CPU 分配,那么当前线程也不一定就是总优先级最高的那个,CPU还是可能被其他线程抢占去。另外值得一提的是Thread.Sleep(0)的作用,就是触发操作系统立刻重新进行一次CPU竞争,竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。
wait(1000)表示将锁释放1000毫秒,到时间后如果锁没有被其他线程占用,则再次得到锁,然后wait方法结束,执行后面的代码,如果锁被其他线程占用,则等待其他线程释放锁。注意,设置了超时时间的wait方法一旦过了超时时间,并不需要其他线程执行notify也能自动解除阻塞,但是如果没设置超时时间的wait方法必须等待其他线程执行notify。
3.5 如何停止一个正在运行的线程
1.使用退出标志终止线程:可以通过设置一个标志位来告诉线程在合适的时候退出运行。这种方法需要线程在适当的时机检查该标志位并自行停止执行。
3.6 T1、T2、T3顺序执行
在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一 个线程,另外一个线程完成该线程继续执行。为了确保三个线程的顺序你应该先启动最后一个(T3调 用T2 ,T2调用T1) ,这样T1就会先完成而T3最后完成。
先定义线程T1,再在T2中joinT1,t1.join(),再在T3中joinT2,t2.join()
3.7 Start、Run方法区别,Wait和notify为什么要在同步方法块
1. start() 和 run() 方法的区别
- start() 方法:
- start() 方法是 Thread 类的一个方法,用于启动一个新的线程,并让线程进入就绪状态,等待调度器调用其 run() 方法开始执行。
- 调用 start() 方法后,系统会创建一个新的线程,然后调用该线程的 run() 方法。
- run() 方法:
- run() 方法是 Runnable 接口或 Thread 类的一个方法,用于定义线程的主体任务。
- 当一个线程启动后,系统会自动调用该线程的 run() 方法来执行具体的任务逻辑。
关键区别:直接调用 run() 方法只会在当前线程中顺序执行该方法的代码,而调用 start() 方法会在新线程中执行 run() 方法的代码。
2.Wait和notify为什么要在同步方法块
- 线程安全性:
- Java 中的对象锁机制(synchronization)用于保护对共享资源的访问。当一个线程持有对象的锁时,其他试图获取同一对象锁的线程将被阻塞,直到持有锁的线程释放锁。
- wait() 和 notify() 方法依赖于对象的监视器(即对象锁),因此必须在持有该监视器的情况下才能调用。
- 等待和唤醒机制的实现:
- 当调用一个对象的 wait() 方法时,当前线程会释放该对象的锁,并进入等待状态,直到另一个线程调用相同对象的 notify() 或 notifyAll() 方法来唤醒它。
- 如果 wait() 和 notify() 不在同步块中调用,就无法保证线程在操作过程中持有正确的对象锁,从而可能导致竞态条件或线程安全问题。
- 原子性操作保证:
- 在同步块中调用 wait() 和 notify() 可以确保它们在执行时是原子操作,即不会被其他线程中断或插入,从而确保操作的一致性和可靠性。
- 如果在非同步块中调用 wait() 和 notify(),则无法保证它们的原子性,因为在调用这些方法期间,其他线程可能会修改对象的状态或者持有对象的锁。
3.8. volatile关键字运用
在Java中,volatile 关键字用于标记一个变量是“易变”的。它的主要作用是确保多个线程能够正确地处理该变量。具体来说,volatile 提供了两个主要的功能:
- 可见性: 当一个变量被 volatile 修饰时,Java内存模型(Java Memory Model,JMM)确保所有线程看到的该变量的值是最新的。也就是说,当一个线程修改了这个变量的值,其他线程能够立即看到这个变化,而不会使用缓存中的旧值。
- 禁止指令重排序: volatile 变量的读写操作会被插入到程序的指令序列中,防止指令重排序优化。这样可以确保指令按照程序的顺序执行,从而避免了可能导致的问题。
使用场景:
- 状态标志位: 当多个线程需要共享一个状态标志位时,通常会将该标志位声明为 volatile,以确保所有线程能够及时看到最新的状态变化。
- 双重检查锁定(Double-Checked Locking): 在单例模式中,双重检查锁定是一种常见的延迟初始化技术。在使用双重检查锁定时,单例实例的引用通常会使用 volatile 关键字修饰,以确保多线程环境下的正确性。
- 轻量级的同步策略: volatile 变量比使用锁(如synchronized)更轻量级,适合在不涉及复杂同步逻辑的情况下,保证变量的可见性和一致性。
3.9 线程池创建方式和工作原理
1.工厂创建:
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.execute(new Runnable() {
@Override
public void run() {
// 线程执行的代码
}
});
2.类创建:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // 核心线程数
10, // 最大线程数
60, // 线程空闲时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>() // 任务队列
);
工作原理
线程池的工作原理可以简述为以下几个步骤:
- 接受任务: 当有新的任务提交给线程池时,线程池会接受任务并将其分配给池中的一个工作线程来执行。
- 执行任务: 线程池中的每个工作线程都会从任务队列中取出任务并执行。如果当前线程池中的线程数小于核心线程数,即使有空闲的线程也会创建新的线程来处理任务,直到达到核心线程数为止。
- 管理线程: 线程池根据配置的参数管理工作线程的数量。它有一个核心线程池,即使没有任务需要执行也会保持存活;而非核心线程在空闲一定时间后会被回收,以节省资源。
- 处理任务队列: 如果任务队列中有等待执行的任务,线程池中的工作线程会按照一定的策略(如 FIFO)从任务队列中取出任务并执行。
- 处理拒绝策略: 如果任务队列已满且线程池中的线程数达到最大线程数,线程池会根据预先设置的拒绝策略来处理新提交的任务。常见的拒绝策略包括丢弃任务、抛出异常、调用者运行等。
优点和适用场景
- 减少线程创建和销毁的开销: 通过线程池可以重复利用已经创建的线程,避免频繁创建和销毁线程带来的性能开销。
- 控制并发线程数量: 线程池可以有效地限制并发线程数量,防止系统资源被过度占用,提高系统稳定性。
- 管理任务: 线程池能够管理和调度任务的执行顺序,处理任务的优先级,以及处理任务执行过程中的异常情况。
- 适用于异步任务处理: 对于需要异步处理的任务,线程池能够提供良好的支持和管理。
3.10 阻塞队列的工作原理
阻塞队列(Blocking Queue)是一种特殊的队列,它支持两种附加操作:当队列满时,插入线程会被阻塞;当队列空时,获取线程会被阻塞。这种特性使得阻塞队列非常适合在多线程编程中作为线程间的通信工具,常用于生产者-消费者模型中。
工作原理
阻塞队列的工作原理可以简述为以下几个关键点:
- 阻塞操作: 当向队列插入元素时(生产者向队列添加任务),如果队列已满,则插入操作会被阻塞,直到队列有空间可以插入元素。同样地,当从队列获取元素时(消费者从队列取出任务),如果队列为空,则获取操作会被阻塞,直到队列中有元素可以获取。
- 线程安全性: 阻塞队列通常是线程安全的,它内部通过锁或者其他同步机制来确保多个线程可以安全地访问队列。这样可以避免在多线程环境下的竞态条件和数据不一致问题。
- 生产者消费者模型: 阻塞队列常用于实现生产者消费者模型。生产者向队列中添加任务,消费者从队列中获取任务并执行。由于阻塞队列的阻塞特性,当生产者生产的速度大于消费者消费的速度时,队列会自动阻塞生产者,直到消费者有能力处理更多的任务;反之亦然。
- 条件变量和通知机制: 阻塞队列内部一般会使用条件变量(Condition)或者类似的机制来实现线程的阻塞和唤醒。当队列状态发生变化(比如队列从空变为非空),会通知相关的线程可以继续执行。
- 内部实现: 具体的阻塞队列实现可以采用不同的数据结构,如数组或链表,并通过不同的算法来管理队列的元素,保证线程安全和高效的插入与移除操作。
应用场景
阻塞队列常用于以下场景:
- 任务调度: 线程池中使用阻塞队列来管理待执行的任务,控制线程数量和任务的执行顺序。
- 消息传递: 多线程之间通过阻塞队列传递消息或事件,实现解耦和并发控制。
- 数据缓冲: 在生产者-消费者模型中作为数据缓冲区,平衡生产者和消费者的速度差异,提高系统的稳定性和吞吐量。
3.11 拒绝策略是怎么进行的
线程池的拒绝策略是指在任务提交到线程池时,如果线程池无法接受新任务(通常是由于工作队列已满),线程池会采取的具体行动方案。下面是一般情况下线程池拒绝策略的执行流程:
-
任务提交:
当有新的任务提交给线程池时,线程池首先会尝试将任务放入其内部的工作队列中(如BlockingQueue
)。如果工作队列已满,即队列中的任务数量已达上限,线程池就会进入拒绝策略的流程。 -
选择拒绝策略:
根据线程池配置的拒绝策略,线程池会执行相应的拒绝处理逻辑。常见的拒绝策略有:- Abort Policy:抛出
RejectedExecutionException
异常,通知任务提交者任务被拒绝。 - Discard Policy:
- Discard Oldest:丢弃队列中最老的任务,然后将新任务加入队列。
- Discard Newest:直接丢弃新的任务,保留队列当前状态。
- Caller-Runs Policy:让提交任务的线程自己执行该任务,而不将任务放入线程池的队列中。
- Abort Policy:抛出
-
执行拒绝策略:
- 如果是抛出异常的策略,线程池会立即抛出异常给任务提交者。
- 如果是丢弃策略,线程池会根据具体的丢弃策略(丢弃最老或最新任务)来调整队列中的任务。
- 如果是
Caller-Runs
策略,线程池会直接让提交任务的线程去执行该任务,从而避免任务丢失,但会影响提交任务线程的性能。
-
通知:
线程池可能会在执行拒绝策略后,通过某种方式通知任务提交者,告知其任务的处理状态或者建议如何重新提交任务。
通过配置适当的拒绝策略,可以根据应用的需求来调整线程池的行为,确保系统在高负载时能够有效地处理任务,同时避免任务丢失或其他不可预料的行为。
锁
1.加锁的方式
在编程中,加锁是一种常见的多线程同步机制,用于控制对共享资源的访问,以防止多个线程同时修改该资源而导致数据不一致或其他并发问题。Java 中常见的加锁方式主要包括以下几种:
-
synchronized 关键字:
- 使用
synchronized
关键字可以对方法或代码块进行加锁。 - 对于方法加锁,当一个线程进入 synchronized 方法时,其他线程将被阻塞,直到当前线程执行完毕释放锁。
- 对于代码块加锁,需要指定一个对象作为锁,线程进入 synchronized 代码块时,必须先获得指定对象的锁才能执行代码块内的内容。
- 使用
-
ReentrantLock 类:
ReentrantLock
是 Java.util.concurrent 包下提供的锁实现类,提供了比 synchronized 更灵活的锁机制。- 使用
lock()
方法获取锁,unlock()
方法释放锁。 - 可以通过
tryLock()
尝试获取锁,lockInterruptibly()
支持可中断的获取锁等特性。
-
ReadWriteLock 接口:
ReadWriteLock
接口支持读写分离的锁,即允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。- 主要实现类为
ReentrantReadWriteLock
。
-
StampedLock 类:
StampedLock
是 JDK8 新增的锁机制,提供了乐观锁和悲观锁的支持。- 悲观锁类似于
ReentrantReadWriteLock
的写锁,乐观锁则允许多个线程同时访问共享资源。
2. 乐观、悲观、同步共享、读写、分段、死锁等理解
理解并区分多种锁机制是编写多线程应用程序时非常重要的,这些概念如下:
-
乐观锁(Optimistic Locking):
- 特点:乐观锁假设不会有其他线程同时修改数据,因此在读取数据后,不立即加锁,而是在更新数据时检查是否有其他线程同时修改了数据。
- 应用场景:适用于读多写少的场景,可以提高并发性能。
- Java 实现:例如使用
StampedLock
提供了乐观读锁的支持。
-
悲观锁(Pessimistic Locking):
- 特点:悲观锁假设会有其他线程同时修改数据,因此在操作数据前先加锁,确保其他线程不能修改数据。
- 应用场景:适用于写多读少或者写操作很频繁的场景,保证数据操作的原子性和一致性。
- Java 实现:例如使用
ReentrantLock
或synchronized
关键字实现的锁机制。
-
同步(Synchronization):
- 特点:在多线程环境中控制对共享资源的访问,确保线程安全性。
- 实现方式:Java 中通过
synchronized
关键字或者ReentrantLock
类来实现同步。
-
读写锁(ReadWrite Lock):
- 特点:区分读操作和写操作的锁机制,允许多个线程同时读取共享资源,但是只允许一个线程写入共享资源。
- 应用场景:适用于读操作频繁但写操作较少的场景,提高读取效率。
- Java 实现:通过
ReentrantReadWriteLock
实现读写锁。
-
分段锁(Segmented Locking):
- 特点:将共享资源划分为多个段(段数通常是线程数的倍数),每个段上都可以加锁,不同线程可以同时操作不同段的数据,从而提高并发性能。
- 应用场景:适用于高并发情况下,数据可以分段存储且各段之间相互独立的场景。
- Java 实现:通常需要自行实现基于分段锁的机制,或者使用并发数据结构如
ConcurrentHashMap
。
-
死锁(Deadlock):
- 特点:多个线程因互相持有对方所需的资源而被永久阻塞的状态。
- 原因:常见原因包括竞争资源、循环等待和没有释放资源。
- 避免方法:通过合理的资源申请顺序、限制资源持有时间、以及使用超时机制等方式来避免死锁的发生。
四个必要条件:
- 互斥条件(Mutual Exclusion):
- 至少有一个资源必须处于非共享模式,即一次只能被一个线程使用。如果一个线程已经获得了该资源(如一个文件、一个锁等),则其他线程必须等待直到该线程释放该资源。
- 占有和等待条件(Hold and Wait):
- 线程至少已经持有一个资源,并且正在等待获取另一个被其他线程持有的资源。换句话说,线程在获取资源时不会释放已经持有的资源,这可能会导致其他线程被阻塞。
- 不可剥夺条件(No Preemption):
- 系统不能强制从一个线程中抢占资源,而只能由持有资源的线程主动释放。这意味着资源只能在被持有线程显式释放时才能被其他线程获取。
- 循环等待条件(Circular Wait):
- 存在一组线程 {T1, T2, …, Tn},其中每个线程正在等待下一个线程所持有的资源。这样就形成了一个循环,每个线程都在等待下一个线程所持有的资源,直到回到第一个线程所需要的资源,从而形成了一个闭环。
预防死锁:
资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
3. 什么是线程安全,怎么确保线程安全
线程安全(Thread Safety)是指在多线程环境中,当多个线程同时访问共享资源时,能够确保程序仍然能够正常工作,不会出现意外的结果或者数据不一致的情况。线程安全是多线程编程中非常重要的概念,因为在并发执行的情况下,多个线程可能会同时访问和修改共享的数据,如果不加以控制和保护,就会引发竞态条件(Race Condition)等问题。
确保线程安全的方法:
1.互斥同步:
使用锁机制确保在同一时刻只有一个线程可以访问共享资源。常见的锁包括 synchronized
关键字和 ReentrantLock
类。
2.非阻塞同步:
因为使用synchronized的时候,只能有一个线程可以获取对象的锁,其他线程就会进入阻塞状态,阻塞状态就会引起线程的挂起和唤醒,会带来很大的性能问题,所以就出现了非阻塞同步的实现方法。
先进行操作,如果没有其他线程争用共享数据,那么操作就成功了,如果共享数据有争用,就采取补偿措施(不断地重试)。
CAS是实现非阻塞同步的计算机指令,它有三个操作数:内存位置,旧的预期值,新值,在执行CAS操作时,当且仅当内存地址的值符合旧的预期值的时候,才会用新值来更新内存地址的值,否则就不执行更新。
缺点 :ABA 问题 版本号来解决
只能保证一个变量的原子操作,解决办法:使用AtomicReference类来保证对象之间的原子性。可以把多个变量放在一个对象里。
3.无同步方案
线程本地存储:将共享数据的可见范围限制在一个线程中。这样无需同步也能保证线程之间不出现数据争用问题。
其实引起线程不安全最根本的原因 就是 :线程对于共享数据的更改会引起程序结果错误。线程安全的解决策略就是:保护共享数据在多线程的情况下,保持正确的取值。