基础概念
什么是线程?线程和进程的区别是什么?
线程 是程序执行的最小单位,它是 CPU 调度和执行的基本单元。一个进程可以包含多个线程,这些线程共享进程的资源(如内存),但每个线程有自己的栈和程序计数器。
线程和进程的区别:
-
定义:
-
进程 是正在执行的程序实例,它是资源分配的基本单位,拥有自己的独立地址空间、内存、数据栈等。
-
线程 是进程中的一个执行单元,多个线程共享进程的资源(内存、文件句柄等)。
-
-
资源:
-
进程 拥有独立的内存空间和系统资源,每个进程有自己的堆和数据段。
-
线程 在同一进程内共享内存空间,因此线程之间的通信比进程间通信更高效。
-
-
创建和销毁:
-
进程 的创建和销毁开销大,因为操作系统需要为每个进程分配独立的内存空间。
-
线程 的创建和销毁相对轻量,开销较小,因为线程共享同一进程的资源。
-
-
通信:
-
进程间通信(IPC) 较为复杂,通常需要通过管道、消息队列、共享内存等方式。
-
线程间通信 相对简单,因为同一进程中的线程共享内存,可以通过共享变量进行通信。
-
-
调度:
-
进程 的切换需要较多的系统资源,因为每个进程都有独立的内存空间和资源。
-
线程 的切换较为轻便,调度和上下文切换的成本较低。
-
总结:进程 是资源分配的基本单位,线程是程序执行的基本单位。线程更轻量,多个线程共享进程资源,但进程之间相互独立。
如何创建线程?有几种方式?
创建线程有两种主要方式:
-
继承
Thread
类:通过继承Thread
类并重写run()
方法来定义线程的执行内容,最后调用start()
方法启动线程。 -
实现
Runnable
接口:通过实现Runnable
接口的run()
方法来定义线程执行的任务,然后将Runnable
实例传递给Thread
类的构造函数,并调用start()
启动线程。
这两种方式的区别在于,继承 Thread
类时只能继承一个类,而实现 Runnable
接口更灵活,因为一个类可以实现多个接口,适合更复杂的应用场景。
Runnable 和 Callable 的区别?
Runnable
和 Callable
都是用于定义线程任务的接口,但它们有一些关键的区别:
-
返回值
-
Runnable
接口的run()
方法没有返回值,也无法抛出异常。 -
Callable
接口的call()
方法有返回值,能够返回一个结果或者抛出异常。
-
-
异常处理
-
Runnable
的run()
方法无法抛出任何检查型异常(checked exceptions),只能抛出运行时异常(unchecked exceptions)。 -
Callable
的call()
方法可以抛出任何类型的异常(包括检查型异常)。
-
-
执行方式
-
Runnable
是传统的线程任务接口,适用于没有返回结果或不需要处理异常的简单任务。 -
Callable
更灵活,适用于需要返回结果或处理异常的任务,通常与ExecutorService
一起使用,可以获取任务的返回值。
-
-
与
Future
的结合-
Runnable
任务无法直接获取结果,因此不能与Future
结合使用来获取任务的返回值。 -
Callable
返回值类型为V
,可以与ExecutorService.submit()
方法一起使用,返回一个Future
对象,通过Future.get()
可以获取任务执行的结果或捕获异常。
-
总结:如果你需要线程执行结果或可能抛出异常,使用 Callable
;如果只是需要执行某些操作而不关心返回值或异常,Runnable
更为简单和直接。
线程的生命周期(状态)有哪些?
线程的生命周期包括以下几种状态:
-
新建(New) 线程在创建后,尚未调用
start()
方法时,处于 新建状态。此时线程对象已经创建,但尚未开始执行。 -
就绪(Runnable) 线程调用
start()
方法后,线程进入 就绪状态,此时线程已经准备好执行,但尚未获取到 CPU 资源。操作系统的线程调度器会根据某些算法决定哪个线程获得 CPU 执行权。 -
运行(Running) 当线程获得 CPU 时间片后,它进入 运行状态,并开始执行
run()
方法中的代码。此时线程正在执行任务。 -
阻塞(Blocked) 线程在等待某些资源(如 I/O 操作、锁资源等)时,会进入 阻塞状态。线程无法继续执行,直到等待的资源可用。
-
等待(Waiting) 线程进入 等待状态,通常是因为调用了
wait()
、join()
或park()
方法,线程会一直等待直到被其他线程唤醒(如调用notify()
、notifyAll()
、interrupt()
等)。 -
超时等待(Timed Waiting) 线程进入 超时等待状态,当线程调用
sleep()
、join(long millis)
、wait(long millis)
等带有超时参数的方法时,线程会在指定的时间内处于等待状态。如果超时未被唤醒,线程会自动回到 就绪状态。 -
终止(Terminated) 线程的
run()
方法执行完毕,或者线程因异常退出时,线程进入 终止状态。此时线程生命周期结束,无法重新启动。
总结:线程的生命周期涉及从新建到终止的多个状态,线程可以在不同状态之间切换,具体状态由线程调度器和程序的运行条件决定。
sleep() 和 wait() 的区别?
sleep()
和 wait()
都是让当前线程暂停执行,但它们有几个关键的区别:
-
作用对象
-
sleep()
是Thread
类的方法,它使当前线程暂停执行指定的时间,当前线程仍然占有 CPU 资源。它不需要持有锁。 -
wait()
是Object
类的方法,它使当前线程暂停执行,并且释放对象的锁,直到线程被其他线程唤醒。wait()
只能在同步块或同步方法中使用。
-
-
暂停时间
-
sleep()
使线程暂停指定的时间(毫秒和纳秒),一旦时间到了,线程会自动回到就绪状态,等待操作系统重新调度。 -
wait()
会让线程一直进入等待状态,直到其他线程通过notify()
或notifyAll()
唤醒它,或者超时(如果指定了超时时间)。
-
-
线程的锁状态
-
sleep()
线程暂停时 不会释放锁,线程仍持有它在进入sleep()
前获得的锁。 -
wait()
线程暂停时 会释放锁,线程放弃当前对象的锁,其他线程可以获得该锁,执行操作。
-
-
异常处理
-
sleep()
会抛出InterruptedException
,如果在sleep()
期间线程被中断,会抛出此异常。 -
wait()
也会抛出InterruptedException
,如果在等待过程中线程被中断,会抛出此异常。
-
-
常见使用场景
-
sleep()
通常用于让线程暂停指定的时间,可以用于周期性的任务、定时器等场景。 -
wait()
通常用于线程间的通信和协作,常见于生产者-消费者模型,线程需要等待某个条件成立或等待资源释放时使用。
-
总结:sleep()
是一个用于让当前线程暂停执行的简单方法,适用于线程的定时任务;而 wait()
主要用于线程间的协作与通信,常用于多线程同步中。
yield() 和 join() 的作用是什么?
yield()
和 join()
都是用于线程控制的静态方法,但它们的作用和使用场景有所不同:
-
yield()
-
作用:
Thread.yield()
方法用于 提示线程调度器 当前线程愿意让出 CPU 执行时间片,允许其他同优先级的线程执行。调用yield()
并不会让当前线程停止执行,只是使当前线程回到就绪状态,调度器会选择其他同优先级的线程执行。 -
特点:
-
线程仍然处于就绪状态,调用
yield()
后,线程不会立即停止,而是根据调度器的策略,可能会被调度出去,其他线程有机会执行。 -
如果没有其他同优先级的线程准备好执行,当前线程可能继续执行。
-
yield()
的调用效果依赖于操作系统和 JVM 的调度策略,并非一定会让出 CPU。
-
-
常见场景:通常用于调度和协调线程的执行,特别是在多线程程序中希望让其他线程有机会执行时。
-
-
join()
-
作用:
Thread.join()
方法使得 当前线程等待另一个线程执行完毕 后再继续执行。即当前线程会被阻塞,直到调用join()
的线程执行完run()
方法。 -
特点:
-
如果线程 A 调用了线程 B 的
join()
方法,线程 A 会被阻塞,直到线程 B 执行完毕。 -
join()
可以指定一个超时时间,即join(long millis)
,如果超时,线程 A 会继续执行。 -
join()
方法常用于线程的协调,确保一个线程在另一个线程执行完成之后再继续执行。
-
-
常见场景:通常用于在多线程程序中,等待某个线程完成任务后再执行后续的操作。例如,在多个线程并行执行时,主线程等待所有子线程完成后再进行下一步操作。
-
总结:
-
yield()
是一种提示线程调度器让当前线程暂停执行,可能会导致当前线程被暂时挂起,等待其他线程执行。 -
join()
是一种同步机制,用于确保当前线程在另一个线程执行完成后再继续执行。
线程安全与同步
什么是线程安全?如何保证线程安全?
线程安全指的是在多线程环境下,多个线程对共享资源进行操作时,能够保证数据的一致性和正确性,不会出现竞态条件或数据错误。
如何保证线程安全?
-
使用同步(synchronized):通过对共享资源加锁,确保同一时间只有一个线程可以访问被锁住的代码块或方法。
-
使用显式锁(如
ReentrantLock
):通过ReentrantLock
提供比synchronized
更加灵活的锁控制,如可以中断的锁或定时锁。 -
原子操作:使用
Atomic
类(如AtomicInteger
)提供原子性操作,无需显式加锁。 -
线程局部变量(ThreadLocal):为每个线程提供独立的变量副本,避免共享数据。
-
不可变对象:使用不可变对象(如
String
),因为它们的状态在创建后不能被改变,从而避免线程安全问题。
总结:确保线程安全的方式主要有同步机制、显式锁、原子操作、线程局部变量等,具体方法根据不同场景选择。
synchronized 关键字的用法?
synchronized
的用法有三种:
-
修饰实例方法,锁的是当前对象
this
,保证同一时刻只有一个线程执行这个方法。 -
修饰静态方法,锁的是当前类的
Class
对象,适用于多个线程访问类级别的资源。 -
修饰代码块,锁的是代码块中指定的对象,可以更细粒度地控制同步范围,提升性能。
目的就是让多个线程在访问共享资源时保持互斥,避免并发问题。
synchronized 和 ReentrantLock 的区别?
synchronized
和 ReentrantLock
都可以实现线程同步,但它们有以下主要区别:
-
锁的可重入性 两者都是可重入锁,线程可以多次获得同一个锁,不会死锁。
-
是否可中断
synchronized
不可中断,线程一旦阻塞,只能等着。ReentrantLock
可以中断,通过lockInterruptibly()
方法实现。 -
是否可超时
synchronized
无法设置超时时间。ReentrantLock
可以通过tryLock(long time)
设置等待锁的时间,超时不再等待。 -
公平性
synchronized
是非公平的,谁先抢到谁先执行,不保证顺序。ReentrantLock
可以设置为公平锁,按线程请求顺序获取锁。 -
锁的释放方式
synchronized
是自动释放,方法或代码块执行完自动释放锁。ReentrantLock
需要手动释放,必须调用unlock()
,否则容易造成死锁。 -
灵活性
ReentrantLock
提供更多高级功能,如条件锁(Condition),可实现更复杂的线程协作;synchronized
只能使用wait()
和notify()
。
总结:简单场景用 synchronized
就够了;需要更强控制力、响应中断、可设置超时、需要公平性时,用 ReentrantLock
更合适。
volatile 关键字的作用?
volatile
是一个轻量级的同步机制,用于保证 可见性 和 禁止指令重排序。
主要作用有两个:
-
可见性:当一个线程修改了被
volatile
修饰的变量,其他线程能立即看到这个修改。它确保了变量从主内存直接读写,而不是使用线程本地缓存。 -
禁止指令重排序:
volatile
会在写操作前插入内存屏障,防止编译器或 CPU 对指令重排,从而保证变量修改的顺序性。
但注意:
-
volatile
不能保证原子性,即多个线程同时修改一个变量时不能保证正确性。比如count++
不是原子操作,用volatile
也无法保证线程安全。 -
一般用于状态标志、双重检查锁等场景。
总结:volatile
适用于保证一个变量在多线程之间的可见性,但不能代替锁来实现复杂的同步逻辑。
什么是 CAS(Compare-And-Swap)?
CAS,全称是 Compare-And-Swap(比较并交换),是一种原子操作,用于实现无锁并发。
它的核心思想是:比较内存中的值是否和预期值相等,如果相等则更新为新值,否则什么都不做。
执行过程包括三个参数:
-
内存位置(变量的地址)
-
预期值(希望内存中的值是这个)
-
新值(如果相等则把它写进去)
只有当变量的当前值等于预期值时,才会被替换成新值,否则就不做任何操作,通常会进行自旋重试。
CAS 的优点:
-
是一种乐观锁机制,不需要加锁,因此效率高。
-
广泛应用于
java.util.concurrent.atomic
包中的原子类,如AtomicInteger
。
缺点:
-
自旋开销:高并发下可能长时间重试,浪费 CPU。
-
只能保证一个变量的原子性,多个变量操作需要配合其他手段。
-
ABA 问题:如果变量从 A 变成 B 又变回 A,CAS 无法识别这种变化。
为了解决 ABA 问题,可以使用 AtomicStampedReference
这类带版本号的原子类。
Atomic 原子类的作用?
Atomic 原子类的作用是提供一套无锁的线程安全操作,用于保证单个变量在并发环境下的原子性操作,避免使用 synchronized
或显式锁带来的性能开销。
这些类位于 java.util.concurrent.atomic
包中,底层依赖于 CAS(Compare-And-Swap)机制 实现。
常见的原子类包括:
-
AtomicInteger / AtomicLong / AtomicBoolean 对基本类型进行原子操作,如自增、自减、比较更新等。
-
AtomicReference 对引用类型进行原子更新,常用于实现非阻塞数据结构或共享对象更新。
-
AtomicStampedReference 解决 ABA 问题,为引用加上版本号或时间戳。
-
AtomicMarkableReference 用布尔标记解决类似 ABA 问题,更轻量。
-
AtomicIntegerArray / AtomicLongArray / AtomicReferenceArray 用于原子地操作数组中的元素。
作用总结:
-
保证数据的可见性和原子性
-
避免加锁,提升并发性能
-
适用于高频简单的并发更新操作,如计数器、状态标志等
但注意:原子类适用于单变量的并发场景,复杂逻辑仍建议使用锁控制。
线程池
为什么要使用线程池?
使用线程池的主要原因是为了更高效、更稳定地管理线程资源。具体来说,有以下几点:
-
降低资源开销 每次创建和销毁线程是昂贵的,线程池通过复用线程,减少了频繁创建销毁的成本。
-
提高响应速度 任务到来时可以直接复用已有线程,无需等待新线程创建,提高响应效率。
-
统一管理线程数量 可以控制并发线程的总量,防止系统因为线程过多而耗尽资源。
-
支持任务调度与拒绝策略 线程池支持任务排队、定时执行、周期执行,并可以设置拒绝策略应对超负荷。
-
更强的可维护性和可监控性 统一的线程管理便于日志跟踪、异常捕获和资源释放,提高系统稳定性。
总结一句话:线程池是为了让线程的使用更加高效、可控、可维护。
ThreadPoolExecutor 的核心参数有哪些?
ThreadPoolExecutor
是 Java 中最灵活的线程池实现类,它的构造函数包含几个核心参数,决定了线程池的行为和性能。主要参数如下:
-
corePoolSize(核心线程数) 核心线程会一直保留(除非设置了 allowCoreThreadTimeOut),即使处于空闲状态也不会被回收。线程数小于该值时,新任务会直接创建线程执行。
-
maximumPoolSize(最大线程数) 线程池能容纳的最大线程数。当任务数过多,阻塞队列满了且核心线程数已满,才会尝试创建非核心线程,直到达到最大线程数。
-
keepAliveTime(空闲线程存活时间) 非核心线程在空闲状态下,超过该时间会被回收。可以通过设置 allowCoreThreadTimeOut 为 true,让核心线程也受到这个限制。
-
unit(时间单位) 配合 keepAliveTime 使用,指定时间的单位,例如 TimeUnit.SECONDS。
-
workQueue(任务队列) 存放等待执行任务的队列,有多种实现,如:
-
LinkedBlockingQueue
(无界,常用于执行大量短期任务) -
ArrayBlockingQueue
(有界,常用于控制内存) -
SynchronousQueue
(不存储任务,适用于任务很快就能执行)
-
-
threadFactory(线程工厂) 用于创建新线程,通常用来自定义线程名字或设置为守护线程。
-
handler(拒绝策略) 当线程池已满且队列也满了,任务无法处理时的策略,常见有:
-
AbortPolicy
(默认,抛出异常) -
CallerRunsPolicy
(由调用者线程执行) -
DiscardPolicy
(直接丢弃) -
DiscardOldestPolicy
(丢弃最旧的任务)
-
这些参数的组合决定了线程池的行为模型,合理配置可以提升并发性能,避免线程资源浪费或过载崩溃。
线程池的拒绝策略有哪些?
线程池的拒绝策略定义了在线程数达到 maximumPoolSize
且队列已满时,如何处理新提交的任务。JDK 提供了四种默认策略,都实现了 RejectedExecutionHandler
接口:
-
AbortPolicy(默认) 直接抛出
RejectedExecutionException
异常,阻止系统继续提交任务,让调用者知道线程池已无法处理新任务。 -
CallerRunsPolicy 由调用线程(提交任务的线程)来执行任务,不抛异常,避免任务丢失,但可能拖慢主线程速度。
-
DiscardPolicy 直接丢弃任务,不抛异常,适合对部分任务容忍丢失的场景。
-
DiscardOldestPolicy 丢弃队列中最旧的任务,然后尝试重新提交当前任务。如果任务提交速率过高,可能导致重要任务被踢出。
除了这四种,也可以自定义拒绝策略,实现 RejectedExecutionHandler
接口,根据业务需求编写逻辑,比如记录日志、发送告警等。
选择哪种策略,需要根据业务容忍度、任务重要性和系统设计来权衡。
Executors 提供的几种线程池?各有什么特点?
Executors
是 Java 提供的线程池工厂类,用于快速创建几种常用线程池,虽然方便,但不推荐在生产中直接使用(理由稍后说)。它提供以下几种线程池:
-
newFixedThreadPool(int nThreads) 创建一个固定大小的线程池,核心线程数 = 最大线程数,使用
LinkedBlockingQueue
(无界队列)。 特点:线程数固定,适合负载稳定、线程数可控的场景。 缺点:任务过多可能导致内存撑爆(因为队列是无界的)。 -
newCachedThreadPool() 创建一个弹性线程池,线程数几乎不受限,空闲线程60秒回收,使用
SynchronousQueue
(不缓存任务)。 特点:适合执行大量短期异步任务。 缺点:高并发下容易创建过多线程,可能导致 OOM。 -
newSingleThreadExecutor() 创建一个单线程线程池,只有一个核心线程,使用
LinkedBlockingQueue
。 特点:所有任务串行执行,保证顺序性,适合场景如日志写入。 缺点:单线程一旦挂掉,所有任务都不能执行。 -
newScheduledThreadPool(int corePoolSize) 创建一个支持定时和周期性任务的线程池,类似定时器功能,使用
DelayedWorkQueue
。 特点:适用于定时任务、周期调度任务。 缺点:底层线程数不受限制,仍有内存风险。
为什么不推荐直接使用 Executors? 它们默认的队列和线程数量配置不合理(如无界队列、无限线程),在高并发下容易造成 OOM 或线程堆积。推荐自己使用 ThreadPoolExecutor
显式设置参数,更安全、可控。阿里 Java 开发手册也强烈建议这一点。
线程池的工作流程是怎样的?
线程池的工作流程大致如下:
-
提交任务 线程池的工作从调用
execute()
或submit()
提交任务开始。任务被封装成一个Runnable
或Callable
对象,然后被提交到线程池。 -
任务排队 任务提交后,首先进入线程池的任务队列(如
BlockingQueue
)。线程池会根据队列的类型和大小决定任务是否能够立即执行。常见的队列类型有:-
无界队列:任务一直可以加入队列,不会抛出异常(例如
LinkedBlockingQueue
)。 -
有界队列:当队列满时,任务会被拒绝(例如
ArrayBlockingQueue
)。 -
同步队列:每个任务都会直接交给一个线程执行,不会被缓存(例如
SynchronousQueue
)。
-
-
线程池工作线程执行任务 如果线程池中有空闲线程,它们会从队列中取出任务并执行。如果没有空闲线程:
-
如果线程池中的线程数小于核心线程数(
corePoolSize
),线程池会创建一个新线程来执行任务。 -
如果线程池中的线程数达到核心线程数,任务将被放入队列,等待已有线程空闲。
-
如果队列已满,且线程池中的线程数小于最大线程数(
maximumPoolSize
),线程池会创建新线程处理任务。 -
如果线程池中的线程数已经达到最大线程数,且队列也已满,线程池会根据设定的拒绝策略(如
AbortPolicy
,CallerRunsPolicy
等)处理任务。
-
-
任务完成和线程回收 当线程池中的线程执行完任务后,会进行线程回收:
-
如果是非核心线程且空闲时间超过了
keepAliveTime
,它们会被回收,线程池的线程数会减少。 -
如果线程池的大小大于
corePoolSize
,并且有空闲线程,这些线程会被销毁,直到线程池的线程数等于核心线程数。
-
-
关闭线程池 当线程池的任务执行完毕或不再需要时,调用
shutdown()
或shutdownNow()
方法来关闭线程池。shutdown()
会等待任务执行完毕后再关闭,而shutdownNow()
会尝试停止正在执行的任务并返回未执行的任务。
总结: 线程池的工作流程是:
-
提交任务 → 判断线程池状态(空闲线程/核心线程/最大线程数) → 执行任务或排队等待 → 任务完成 → 线程回收。 线程池通过合理的资源管理和任务调度,优化了线程的创建、复用和销毁,提升了系统的并发处理能力和性能。
并发工具类
CountDownLatch 和 CyclicBarrier 的区别?
CountDownLatch
和 CyclicBarrier
都是 Java 中常用的同步工具类,虽然它们都用于协调多个线程的执行,但它们的工作原理和应用场景有所不同。具体区别如下:
1. 功能和目的
-
CountDownLatch:用于使一个或多个线程等待其他线程完成某些操作后再执行。通过调用
countDown()
来减少计数器,计数器为零时,所有等待的线程可以继续执行。 -
CyclicBarrier:用于使一组线程互相等待,直到所有线程都达到某个屏障点后再继续执行。线程通过调用
await()
来等待,直到所有线程都调用await()
。
2. 计数器的可重用性
-
CountDownLatch:计数器在达到零后无法重置,一旦计数器减为零,
CountDownLatch
就会结束,不能再重新使用。适用于一次性事件的同步,例如等待所有线程完成某些初始化工作后再继续。 -
CyclicBarrier:计数器在所有线程都到达屏障点后会自动重置,可以重复使用。适用于需要多个阶段、多个线程周期性等待的场景,比如多阶段的并行计算。
3. 使用场景
-
CountDownLatch:典型的场景是等待多个线程执行完某些任务后再继续执行,如主线程等待多个工作线程的完成。
-
CyclicBarrier:典型的场景是多个线程并行执行某些任务,并在完成一个阶段后等待其他线程到达同一阶段,例如多个计算任务在同一屏障点等待,确保同步执行。
4. 计数器操作
-
CountDownLatch:使用
countDown()
方法来减少计数器的值,直到值为零,线程才能继续执行。await()
方法用于等待计数器为零时的通知。 -
CyclicBarrier:使用
await()
方法使线程阻塞,直到所有线程都调用await()
,计数器达到设定值后,所有线程才会被唤醒并继续执行。
5. 线程等待方式
-
CountDownLatch:只能等待,无法重置,所有线程必须在计数器为零后才能继续。
-
CyclicBarrier:线程在到达屏障点后会等待,所有线程都到达后,才会继续执行,可以进行多次等待。
总结
-
CountDownLatch 用于等待某个事件的发生,适用于一次性等待场景。
-
CyclicBarrier 用于同步多线程在某个共同点上进行等待,适用于多阶段的同步场景,且具有可重用性。
Semaphore 的作用是什么?
Semaphore
是 Java 中的一个并发控制工具类,主要用于控制访问某个资源的线程数量。它可以用来限制同时访问某些资源的线程数,避免系统因为过多线程访问共享资源而产生问题(如资源竞争、性能下降、线程安全问题等)。
主要作用:
-
限制资源访问数量
Semaphore
通过维护一个计数器来控制同时访问某个资源的线程数量。计数器的初始值代表可以同时访问资源的最大线程数。如果有线程请求访问资源,计数器的值减一;如果计数器值为零,新的线程就会被阻塞,直到有线程释放资源(通过release()
方法)。 -
实现并发控制 它用于实现类似“并发限制”的场景,如限制线程池的并发线程数、数据库连接池、限制访问某个共享资源的线程数量等。
-
避免线程过度竞争 在某些场景下,可以用
Semaphore
限制线程访问某些资源,避免资源过度竞争导致的性能瓶颈或者线程崩溃。
核心方法:
-
acquire():获取许可(即请求资源)。如果没有足够的许可,线程会被阻塞,直到有许可可用。
-
release():释放许可,增加计数器的值,允许其他线程访问资源。
-
availablePermits():返回当前可用的许可数量。
-
drainPermits():返回并清空当前所有可用的许可数量。
使用场景:
-
控制并发数:例如限制数据库连接池中最大连接数、控制请求访问频率等。
-
并发限流:当资源访问过于频繁时,可以使用
Semaphore
来限制并发数,保护资源。 -
异步任务管理:可以通过
Semaphore
来限制同时执行的异步任务数量,防止系统过载。
总结:
Semaphore
主要用于控制并发访问的数量,可以限制对共享资源的访问。它通过获取和释放许可来实现线程间的同步与资源控制。
ThreadLocal 的原理和使用场景?
原理
ThreadLocal
是一个线程局部变量类,每个线程都会有自己的变量副本,线程之间不会共享这些副本。它通过每个线程内部维护一个 ThreadLocalMap
来实现这一点,ThreadLocalMap
将 ThreadLocal
作为键,线程对应的变量作为值存储。当调用 get()
或 set()
时,实际上操作的是当前线程的局部变量副本,而不是共享变量。
使用场景
-
避免共享数据的线程安全问题:当多个线程需要使用相同类型的数据时,使用
ThreadLocal
可以为每个线程提供独立的副本,避免了线程间的共享问题和同步开销。 -
性能优化:对于一些频繁访问的资源(如数据库连接、日期格式化等),使用
ThreadLocal
可以避免多线程的同步冲突,提升性能。 -
线程独立存储数据:在 Web 开发中,可以用
ThreadLocal
存储线程局部数据,比如每个请求的会话信息,避免不同请求间的数据共享。
简而言之,ThreadLocal
用于确保每个线程都可以拥有自己独立的存储空间,适合处理线程独立数据的场景。
Fork/Join 框架的作用?
Fork/Join
框架是 Java 7 引入的一个并行计算框架,旨在简化并行任务的处理,特别是对于可以被分解成小任务并且最终结果可以合并的计算。其核心思想是分治法(Divide and Conquer),将大任务拆分为多个小任务,分别执行后再合并结果。
作用
-
并行化任务
Fork/Join
框架可以通过拆分大任务为多个小任务,并将小任务并行执行,从而有效地利用多核处理器,提高计算效率。 -
任务分解与合并 它适合用来处理那些能够递归分解为小子问题的问题。每个子任务完成后,将其结果汇总或合并为最终结果。
-
任务调度优化
Fork/Join
框架通过使用工作窃取算法(Work Stealing)来优化任务调度。空闲的工作线程可以窃取其他线程未完成的任务,从而提高系统的吞吐量和资源利用率。
主要组件
-
ForkJoinPool
ForkJoinPool
是Fork/Join
框架的核心执行池,它继承自AbstractExecutorService
。ForkJoinPool
具有工作窃取算法,可以有效管理多线程的任务执行。 -
ForkJoinTask
ForkJoinTask
是框架中任务的抽象类,包含两个重要的子类:-
RecursiveTask:表示有返回结果的任务。
-
RecursiveAction:表示没有返回结果的任务。
-
工作原理
-
分割任务:通过
fork()
方法将任务分解成多个子任务。 -
执行任务:通过
join()
方法等待子任务完成并返回结果。 -
合并结果:当子任务完成后,将它们的结果合并,最终生成父任务的结果。
示例应用场景
-
计算大规模的 Fibonacci 数列。
-
并行计算大规模数组的和。
-
图像处理、搜索引擎等需要进行分治操作的计算任务。
总结
Fork/Join
框架主要用于将一个大任务拆分为多个小任务,并行执行,适合分治型计算。它通过工作窃取算法来优化多线程执行,能够有效利用多核处理器,提高并行计算的性能。
锁与并发优化
乐观锁和悲观锁的区别?
乐观锁和悲观锁是两种常见的并发控制机制,用于在多线程环境下处理共享资源的访问,防止数据不一致和竞争条件。它们的主要区别在于对锁的使用和对并发访问的态度不同。
1. 悲观锁(Pessimistic Lock)
-
概念:悲观锁的思想是认为在多线程环境下,数据会频繁发生冲突,因此在访问数据时,线程会先加锁,其他线程只能等待,直到锁被释放后才能继续执行。换句话说,悲观锁假设在访问共享资源时,必然会发生冲突,因此要采取预防措施(加锁)。
-
实现方式:通常通过
synchronized
关键字或者ReentrantLock
等实现。 -
特点:
-
性能开销较大:因为每次访问资源时都会加锁,导致其他线程必须等待锁释放,可能会引起线程阻塞。
-
适用于冲突频繁的场景:当多个线程同时操作共享资源的概率较高时,悲观锁可以有效防止数据不一致。
-
-
应用场景:适用于并发访问较为激烈的环境,例如银行转账、库存更新等需要保证数据一致性的场景。
2. 乐观锁(Optimistic Lock)
-
概念:乐观锁的思想是认为在多线程环境下,数据冲突的概率较低,因此在访问数据时,不会加锁,而是直接操作数据。操作完成后再检查数据是否被其他线程修改。如果数据没有被修改,操作就成功;如果数据被修改了,乐观锁会重新尝试操作。这种机制通常依赖于“版本控制”或“比较和交换”机制。
-
实现方式:常用的是CAS(Compare and Swap),通过比较值是否发生变化来判断是否发生了并发冲突。或者通过数据库中的版本号机制来判断数据是否已被修改。
-
特点:
-
性能较好:因为乐观锁不会在每次访问时加锁,线程不需要阻塞,减少了锁的开销,性能相对较高。
-
适用于冲突较少的场景:如果数据访问冲突的概率较低,乐观锁可以减少同步开销,提升并发性能。
-
-
应用场景:适用于并发访问较少或冲突不频繁的环境,例如缓存更新、批量数据处理等。
3. 对比总结
特性 | 乐观锁 (Optimistic Lock) | 悲观锁 (Pessimistic Lock) |
---|---|---|
锁的使用 | 不加锁,操作前后进行冲突检查 | 每次操作前都加锁 |
性能 | 性能较高,适用于冲突较少的场景 | 性能较差,适用于冲突频繁的场景 |
阻塞情况 | 无阻塞,只有在操作完成后检查冲突 | 会阻塞,直到锁被释放才能继续执行 |
实现方式 | CAS、版本号控制等 | synchronized 、ReentrantLock 等 |
适用场景 | 并发访问较少或冲突不频繁的场景 | 并发访问较多,数据一致性要求高的场景 |
4. 总结
-
悲观锁适用于数据冲突频繁的场景,通过加锁来防止数据冲突,但可能会带来性能损失。
-
乐观锁适用于数据冲突较少的场景,通过不加锁的方式提高性能,但需要额外的冲突检测机制(如CAS或版本控制)。
什么是死锁?如何避免死锁?
死锁是什么?
死锁(Deadlock)是指在多个线程之间发生一种特殊的情况,其中每个线程都在等待其他线程释放它所需要的资源,但这些线程都无法继续执行,导致程序进入一个永远等待的状态。简而言之,死锁发生时,程序中的所有线程都在相互等待资源,造成了无休止的等待,从而导致程序无法继续执行。
死锁的必要条件(四个条件)
死锁通常是由以下四个条件共同作用导致的,它们被称为死锁的四个必要条件:
-
互斥条件(Mutual Exclusion) 至少有一个资源处于“只允许一个线程访问”的状态,也就是说,某个资源只能被一个线程占用。
-
请求与保持条件(Hold and Wait) 一个线程持有某些资源,同时又请求其他资源,但是请求的资源被其他线程持有,造成线程阻塞,不能继续执行。
-
不剥夺条件(No Preemption) 已分配给线程的资源在未使用完之前不能被强行剥夺,只能在线程完成任务后才释放资源。
-
循环等待条件(Circular Wait) 线程集合中的线程相互等待对方持有的资源,形成一个闭环。例如,线程A等待线程B的资源,线程B等待线程C的资源,线程C等待线程A的资源。
当这四个条件同时满足时,就可能发生死锁。
如何避免死锁?
为了避免死锁,可以采取以下几种策略:
1. 避免死锁的策略:资源请求顺序
最常见的一种避免死锁的方法是资源排序。确保所有线程按照一致的顺序申请资源。通过避免循环等待,可以减少死锁的发生。
-
策略:为所有共享资源分配一个全局顺序,线程必须按照资源的顺序申请资源。这样,可以避免循环等待的条件。
例如,如果有资源A和资源B,线程必须先请求资源A,然后请求资源B,而不能反过来。
2. 使用超时机制
可以为每个线程的资源请求设置一个超时时间。如果线程在超时时间内未能获得资源,它将放弃当前的请求,释放已占用的资源,并尝试重新执行或处理其他任务。
-
策略:线程在请求资源时使用带有超时限制的
tryLock()
或在数据库中使用SELECT ... FOR UPDATE
配合timeout
来避免死锁。
3. 死锁检测
通过使用监控或日志等方式实时检测系统中的死锁情况,发现死锁后采取措施,比如回滚某些操作或强制中断某些线程。
-
策略:通过定期检查线程间的资源请求情况,检测是否有死锁发生。如果发现死锁,系统可以采取回滚、重试或中断某些线程的操作。
4. 减少持有锁的时间
尽量缩短线程持有锁的时间,确保在获取锁时,尽量减少在锁定资源期间执行的工作,减少死锁的发生概率。
-
策略:线程在持有锁时,只执行必要的工作,尽早释放锁。避免在持有锁时执行耗时操作或其他可能引发阻塞的任务。
5. 使用更高层次的锁机制
某些高级锁机制,如 ReentrantLock
提供了内置的死锁预防机制(如 tryLock()
)和超时处理机制,能够更好地控制锁的获取与释放,避免死锁。
6. 锁粒度控制
控制锁的粒度,尽量减少每次获取锁的资源范围,避免多个锁的竞争。可以通过分层锁、细化锁的粒度来降低死锁的概率。
-
策略:尽量避免锁定多个资源,或者尽量减少锁定资源的数量。
总结
-
死锁 是指在多线程环境下,多个线程互相等待资源,导致程序无法继续执行的情况。
-
避免死锁的策略 主要包括资源请求顺序、超时机制、死锁检测、减少锁持有时间、使用高层锁机制和控制锁的粒度等方法。
-
为了有效防止死锁,通常需要根据具体的应用场景和锁策略做出适当的调整和优化。
AQS(AbstractQueuedSynchronizer)的作用?
AQS(AbstractQueuedSynchronizer)是 Java 并发包(java.util.concurrent
)中的一个抽象类,提供了一种基于队列的同步器框架,用于构建自定义的同步工具(如 ReentrantLock
、CountDownLatch
、Semaphore
等)。它是实现各种同步机制的基础框架,可以帮助开发者在并发编程中简化锁的实现。
AQS 的作用
AQS 提供了一个统一的抽象框架,用于实现同步器的基本操作,如请求锁、释放锁、排队等待等。它通过一个双向队列(FIFO 队列)管理多个线程,确保线程按照请求的顺序访问共享资源。AQS 主要负责以下几项工作:
-
队列管理 AQS 使用一个队列来管理线程的排队。在同步操作过程中,如果某个线程无法立即获取到锁或资源,它会被放入队列中,等待其他线程释放资源后再进行获取。AQS 管理这些线程的入队、出队、等待和唤醒操作。
-
线程的获取与释放 AQS 提供了获取和释放同步资源的基本操作,如
acquire()
和release()
。通过继承 AQS 并实现其中的抽象方法,开发者可以根据自己的需求定制不同的同步器。 -
共享和独占模式 AQS 支持两种不同的同步模式:独占模式和共享模式。
-
独占模式:某个线程获取资源后,其他线程无法获取该资源,直到持有资源的线程释放。
-
共享模式:多个线程可以同时获取资源,直到资源达到上限才会阻塞等待。
-
-
线程阻塞与唤醒 AQS 管理线程的阻塞与唤醒机制。如果当前线程无法获取到资源,它会被加入队列并进入阻塞状态。其他线程释放资源后,会唤醒队列中的线程,使其能够继续执行。
-
底层支持自定义同步器 通过继承 AQS 并实现其方法,开发者可以轻松实现自定义的同步器。比如
ReentrantLock
、CountDownLatch
、Semaphore
、ReadWriteLock
等都可以通过 AQS 来实现。
AQS 的核心方法
-
acquire(int arg): 请求获取资源,并尝试根据给定的参数(如尝试次数、超时等)获得同步资源。通常用于实现锁的获取逻辑。
-
release(int arg): 释放资源,表示当前线程完成任务后释放锁或者同步资源。通常用于实现锁的释放逻辑。
-
tryAcquire(int arg): 尝试获取资源,通常会被自定义同步器重写,以决定是否能够立即获取锁。
-
tryRelease(int arg): 尝试释放资源,通常会被自定义同步器重写,执行一些资源释放后的后处理操作。
-
acquireShared(int arg): 共享模式下请求资源。通常用于如信号量等共享资源的获取。
-
releaseShared(int arg): 共享模式下释放资源,通常用于信号量等资源的释放。
AQS 的工作原理
-
队列和线程阻塞 当一个线程请求获取资源时,如果该资源当前不可用,线程将被加入到 AQS 的等待队列中。线程进入等待状态,直到有线程释放资源,并唤醒它。
-
资源的竞争和获取 资源的获取通常由
tryAcquire
或tryAcquireShared
方法实现。如果这些方法成功获取了资源,线程就可以开始执行。如果失败,则进入队列,等待被唤醒。 -
资源的释放 线程完成任务后,通过
release
或releaseShared
方法释放资源。此时,AQS 会尝试唤醒队列中的其他线程,让它们有机会获取资源。 -
队列的管理 AQS 会确保线程按照请求的顺序进行排队等待,FIFO 顺序。如果线程获取资源成功,它就会从队列中移除,继续执行。
AQS 的应用
AQS 本身并不会直接用于同步操作,而是作为一个底层工具,帮助开发者构建自定义的同步工具。基于 AQS,Java 提供了很多常见的同步工具,例如:
-
ReentrantLock:可重入锁,实现了独占锁的功能。
-
CountDownLatch:允许一个或多个线程等待其他线程执行完成后再继续执行。
-
Semaphore:限制某个资源的最大并发线程数,控制访问资源的线程数。
-
ReadWriteLock:读写锁,允许多个线程同时读取,但在写操作时独占资源。
-
CyclicBarrier:让一组线程在某个阶段等待,直到所有线程都达到这个阶段。
总结
AQS 是 Java 并发包中的一个核心类,用于构建自定义同步器。它通过一个队列管理线程的排队,支持独占模式和共享模式,提供线程获取和释放资源的基本操作,帮助开发者简化复杂的同步控制。通过 AQS,开发者可以轻松实现高效的并发控制机制,如锁、信号量、计数器等同步工具。
什么是偏向锁、轻量级锁、重量级锁?
偏向锁、轻量级锁和重量级锁是 Java 虚拟机(JVM)中针对线程竞争的不同优化策略。它们的设计目的是为了减少锁竞争和提高性能。它们在 JDK 的不同版本中不断改进,尤其是随着 JDK 1.6 和之后版本的引入,使得锁的性能得到了显著提升。下面是它们的具体解释和区别。
1. 偏向锁(Biased Locking)
偏向锁是 JVM 为了优化单线程场景下的锁竞争而提出的一种锁优化策略。其目的是减少获取锁的开销,特别是在只有一个线程访问同步块时。
-
偏向锁的工作原理: 偏向锁的基本思想是当一个线程第一次获得锁时,会将锁的标记记录在对象头中,并且在后续获取锁时,不需要做任何的同步操作,直接获取对象的锁。只有当其他线程竞争该锁时,才会撤销偏向锁,转为轻量级锁或重量级锁。
-
何时使用: 偏向锁适用于绝大多数情况下只有一个线程访问某个对象的场景,比如缓存操作、日志记录等。它减少了每次获取锁时的性能开销。
-
撤销条件: 偏向锁会在以下情况下被撤销:
-
另一个线程尝试获取该锁。
-
当前线程被中断。
-
当前线程在获取锁时发生了死锁。
-
2. 轻量级锁(Lightweight Locking)
轻量级锁是为了解决偏向锁撤销后的锁竞争问题而提出的优化机制。它的目标是提高锁的性能,避免每次加锁都进入重量级锁的状态。
-
轻量级锁的工作原理: 轻量级锁的基本思路是线程在获取锁时,会先尝试使用一个称为锁标记(Lock Record)的结构来判断是否已经有线程持有该锁。这个过程是无锁的,只有在发生锁竞争时,才会转为重量级锁。轻量级锁的实现依赖于 CAS(Compare-And-Swap) 操作,如果 CAS 成功,则锁定成功;否则,如果锁被其他线程占用,则会撤销轻量级锁并进入阻塞状态(进入重量级锁)。
-
何时使用: 适用于少量线程竞争的场景。如果只有一个线程访问共享资源,轻量级锁能够提供较好的性能。如果有多个线程竞争,轻量级锁会变成重量级锁,导致性能下降。
-
特点:
-
轻量级锁不需要进行系统调用,尽量避免了进入阻塞队列。
-
线程只有在竞争时才会升级为重量级锁,从而减少了不必要的锁竞争。
-
3. 重量级锁(Heavyweight Locking)
重量级锁是传统的锁机制,它通常是指 synchronized
锁。当锁竞争较激烈时,JVM 会将轻量级锁升级为重量级锁。重量级锁会导致线程阻塞和唤醒,通常会引入系统调用,影响性能。
-
重量级锁的工作原理: 当多个线程争用同一个锁时,JVM 会使用操作系统的互斥机制(例如互斥量或信号量)来保护共享资源。当一个线程获取不到锁时,它会被挂起并进入阻塞状态,直到其他线程释放锁为止。
-
何时使用: 在高并发情况下,当多个线程争用同一个锁时,轻量级锁无法解决问题,锁会被升级为重量级锁。重量级锁会严重影响性能,导致线程上下文切换和阻塞。
-
特点:
-
在多线程竞争激烈时,重量级锁会涉及线程的上下文切换和内核调度,性能开销较大。
-
进入重量级锁后,线程会被阻塞,直到锁释放。
-
4. 总结
锁类型 | 描述 | 适用场景 | 性能表现 |
---|---|---|---|
偏向锁 | 优化单线程场景,当只有一个线程竞争时不会进行加锁操作。 | 适用于只有一个线程操作的场景,线程竞争少。 | 性能最好,几乎没有开销。 |
轻量级锁 | 当多个线程竞争时,采用 CAS 等机制减少锁的开销,避免进入阻塞状态。 | 线程竞争较少的情况。 | 性能优于重量级锁,但在竞争时会升级为重量级锁。 |
重量级锁 | 传统的锁机制,线程会进入阻塞状态,通过操作系统的机制实现同步。 | 线程竞争激烈的情况。 | 性能差,涉及阻塞、唤醒、上下文切换等开销。 |
5. JVM 实现锁优化
JVM 会根据运行时的不同情况动态调整锁的状态:
-
初始时可能是偏向锁,适应单线程环境。
-
如果有多个线程竞争,偏向锁会升级为轻量级锁。
-
当轻量级锁无法满足并发需求时,锁会被升级为重量级锁。
这种锁的升级机制是为了尽可能减少锁的开销,提供最佳的性能。因此,JVM 的锁优化是动态的,并且会根据线程竞争的情况做出相应的调整。
什么是自旋锁?
自旋锁是一种同步机制,它通过不断循环检查某个条件(例如锁的状态)来获取锁,而不是让线程进入阻塞状态。线程在获取锁时,如果发现锁已被其他线程占用,它不会立即放弃,而是会持续检查锁是否释放,直到获得锁为止。
自旋锁的特点:
-
无阻塞:自旋锁不会让线程进入阻塞状态,它让线程持续检查锁的状态。这样避免了线程阻塞和唤醒的开销,减少了上下文切换。
-
适用于锁持有时间短的场景:当锁的持有时间很短时,自旋锁比阻塞式锁(如
synchronized
)更高效,因为它避免了线程的上下文切换。 -
CPU消耗:在高并发情况下,线程会一直自旋等待锁,导致占用大量 CPU 资源。如果锁的持有时间过长,CPU 会被浪费掉。
-
没有公平性保证:自旋锁不能保证先到的线程优先获取锁,可能会导致某些线程一直无法获取到锁。
自旋锁的应用场景:
-
锁持有时间短的场景:自旋锁适合用于锁持有时间非常短的场景,例如一个线程执行的操作只需要几次 CPU 时钟周期就能完成。
-
高并发的场景:在某些高并发场景下,如果大部分时间只有一个线程能成功获取锁,其他线程可以通过自旋快速等待,避免了不必要的上下文切换。
-
避免线程阻塞:自旋锁能避免线程被挂起,特别适用于锁竞争较小的场景。
自旋锁的缺点:
-
CPU占用高:如果锁的持有时间较长,线程将会长时间自旋,占用大量 CPU 资源,降低系统的性能。
-
锁竞争激烈时不适用:当多个线程频繁竞争锁时,自旋锁可能会导致严重的性能问题,因为线程会一直消耗 CPU 资源。
-
没有公平性:自旋锁不保证锁的获取顺序,因此可能会出现某些线程长时间无法获得锁的情况。
总的来说,自旋锁在某些场景下能提高性能,但在锁竞争激烈或锁持有时间长的情况下,它的缺点会非常明显,因此使用时需要谨慎。
JMM(Java 内存模型)
什么是 JMM?
JMM(Java Memory Model,Java内存模型)是 Java 程序中线程间通信和共享数据的规范,它定义了 Java 程序中不同线程如何访问共享变量、如何同步变量的值,以及如何确保多线程程序的正确执行。JMM 主要目的是确保 Java 程序的可见性、原子性和有序性,以便在多线程环境下避免出现不可预料的行为。
JMM的核心概念
-
内存共享: 在 Java 中,所有线程共享一块内存区域。每个线程有自己的工作内存,线程的工作内存存储了它所使用的变量副本,而共享变量存储在主内存中。线程对共享变量的读写操作必须通过主内存来进行。
-
主内存和工作内存:
-
主内存:存储所有线程共享的变量,线程通过主内存进行读写操作。
-
工作内存:每个线程有自己的工作内存,工作内存是线程对变量的本地副本,线程在工作内存中操作共享变量。
-
-
JMM的目标: JMM 的设计目标是确保多线程编程中共享变量的可见性、原子性和有序性:
-
可见性:当一个线程修改了共享变量的值,其他线程能及时看到这个修改。
-
原子性:一个操作要么全部执行成功,要么完全不执行,不会受到其他线程的干扰。
-
有序性:程序中代码的执行顺序必须符合语义,避免由于指令重排序导致的异常行为。
-
JMM的关键规则
-
主内存与工作内存的交互规则:
-
读取共享变量:当线程要读取共享变量时,必须通过工作内存读取主内存中的变量。
-
写入共享变量:当线程要修改共享变量时,必须将修改的值写入主内存。
-
-
volatile变量:
-
使用
volatile
关键字修饰的变量,能够保证线程对该变量的修改对其他线程是可见的。即当一个线程修改了volatile
变量的值,其他线程立即能够看到该变化。 -
但是,
volatile
并不能保证复合操作的原子性,像++
这种操作仍然会出现线程安全问题。
-
-
Happens-Before原则: JMM定义了happens-before原则,用于确定线程间的操作顺序。它描述了不同线程之间的操作是否能够保证顺序执行。
-
程序顺序规则:一个线程内的操作按照程序的顺序执行。
-
锁规则:在进入某个锁(如
synchronized
)之前的操作,happens-before 锁释放之后的操作。 -
volatile规则:对
volatile
变量的写操作 happens-before 任何后续对该变量的读操作。
-
-
重排序和指令重排: JMM允许一定程度的指令重排序,以提高性能。但这可能会导致程序执行结果与预期不一致。为了避免不必要的重排序,JMM通过同步机制(如
synchronized
、volatile
)来保证线程之间的正确执行顺序。
JMM的内存可见性问题
在多线程环境中,由于每个线程有自己的工作内存,线程对共享变量的修改在某些情况下可能不会及时传递到其他线程。例如:
-
线程1修改共享变量A的值,但线程2未能及时读取到线程1修改后的最新值。这种情况称为内存可见性问题。
解决这个问题的一些方法包括:
-
使用
volatile
关键字,确保修改立即对其他线程可见。 -
使用
synchronized
块,确保对共享变量的访问是同步的,避免内存可见性问题。
JMM的原子性和有序性
-
原子性:JMM确保一些基本操作(如读取、写入)是原子的,但像
++
这样的复合操作不是原子的,需要通过同步机制(例如synchronized
或Atomic
类)来保证原子性。 -
有序性:JMM通过允许一定程度的指令重排来提高性能。但为了避免重排序导致的错误,需要使用同步机制来确保正确的执行顺序。比如
synchronized
和volatile
可以确保有序性。
总结
JMM定义了 Java 程序中多线程如何正确地共享数据,它通过规定内存模型的规则,保证了线程间的可见性、原子性和有序性。理解 JMM 可以帮助我们更好地设计多线程程序,避免常见的并发问题。
happens-before 原则是什么?
Happens-Before原则是Java内存模型(JMM)中定义的线程操作顺序的规则,它保证了多线程环境下线程之间的操作顺序及可见性。它的基本意思是:一个操作必须发生在另一个操作之前,从而确保前一个操作的结果能被后续操作看到。
Happens-Before的核心规则
-
程序顺序规则:一个线程内的操作总是按照程序顺序执行的,也就是说,前面的操作 happens-before 后面的操作。
-
锁规则:在多个线程之间,如果一个线程释放了锁,那么另一个线程在获取该锁时,释放锁的操作 happens-before 获取锁的操作。这样,第二个线程能够看到第一个线程对共享变量的修改。
-
volatile规则:对
volatile
变量的写操作 happens-before 任何后续对该变量的读操作。即当一个线程修改了volatile
变量的值,其他线程立刻可以看到修改后的值。 -
线程启动规则:当一个线程调用另一个线程的
start()
方法时,start()
操作 happens-before 被启动线程的任何其他操作,确保启动线程的状态是可见的。 -
线程结束规则:当一个线程调用
join()
方法等待另一个线程结束时,join()
操作 happens-before 被调用线程的结束操作。确保前一个线程的执行完成,后续的线程才能继续。 -
中断规则:线程的中断操作 happens-before
isInterrupted()
检查。
为什么Happens-Before很重要?
Happens-Before原则定义了线程之间的操作顺序,确保了在多线程程序中,线程间的共享数据的一致性和可见性。它帮助开发者理解在不同线程之间如何正确地同步数据,从而避免线程安全问题。
什么是内存可见性问题?如何解决?
内存可见性问题是指在多线程环境下,一个线程对共享变量的修改,其他线程可能无法立即看到该修改的情况。这种问题会导致线程间的同步失效,造成程序的行为不可预测。
内存可见性问题的原因:
-
线程本地缓存:现代处理器为提高性能,会对线程的工作内存进行优化,每个线程都有自己的一块工作内存,线程对共享变量的修改可能只会影响到自己本地的缓存,而不会立刻同步到主内存中,导致其他线程无法看到这个修改。
-
CPU重排序:为了提高执行效率,CPU可能会对指令进行重排序,这可能会改变程序中操作的执行顺序,导致一个线程对共享变量的修改在另一个线程访问之前不可见。
-
不适当的同步:如果多个线程对共享数据进行操作时没有适当的同步机制,也会导致线程间的共享数据不一致,无法保证修改的可见性。
如何解决内存可见性问题?
-
使用
volatile
关键字:volatile
关键字保证了对该变量的修改对所有线程是可见的。即每次读取volatile
变量时,都会直接从主内存中读取最新的值,而不是从线程的工作内存中读取。这就确保了多个线程间对该变量的修改能够及时被其他线程看到。 -
使用
synchronized
关键字:synchronized
关键字通过加锁来确保共享数据的可见性和原子性。每个线程在进入一个同步块时,必须首先获取锁,并在执行完同步块后释放锁。这样,线程之间的共享变量修改会通过锁的机制同步到主内存,从而保证数据的可见性。 -
使用
java.util.concurrent
包中的工具类:-
Atomic
类:例如AtomicInteger
,AtomicBoolean
等类提供了原子性操作,保证线程安全,并且它们的操作通常是直接通过硬件提供的原子操作实现的。 -
CountDownLatch
、CyclicBarrier
等同步工具:这些工具类能够帮助线程之间协调执行顺序,从而保证共享变量在不同线程之间的可见性。
-
-
使用
final
关键字: 对于一些不可变的对象,使用final
关键字可以保证线程安全。当一个对象被标记为final
时,JVM 会保证该对象的初始化操作是线程安全的,也就是说,final
字段一旦赋值,其他线程就能够立即看到该字段的值。 -
使用内存屏障(Memory Barrier): 内存屏障是硬件或操作系统提供的底层机制,用来防止指令重排序,确保操作的顺序性。虽然这种机制通常由 JVM 和底层硬件处理,但我们可以通过使用高层次的同步机制(如
volatile
和synchronized
)来间接控制内存屏障的使用。
总结
内存可见性问题会导致多线程程序出现不可预知的错误。为了解决这个问题,我们可以通过使用 volatile
、synchronized
等同步机制,确保线程间对共享变量的修改能够及时且正确地传播到其他线程,从而保证程序的一致性和可靠性。
final 关键字在多线程中的作用?
抱歉,下面我不会再提供代码,只会解释概念。
在多线程中,final
关键字的作用主要体现在以下几个方面:
1. 保证对象的安全发布
在多线程环境下,当多个线程共享同一个对象时,确保对象在构造完成后能够正确地被其他线程看到是至关重要的。final
关键字能够保证一个对象在构造完成后,其值对其他线程是可见的。这是因为 final
修饰的字段保证了在对象构造过程中,不会出现字段的重排序或值的延迟写入,使得构造完成后的对象状态对其他线程是一致的。
2. 创建不可变对象
final
可以用于字段,确保字段在对象构造后不可修改。通过 final
,可以创建不可变对象(Immutable Object)。不可变对象的状态在构造完成后不能被改变,因此在多线程环境下,不需要担心多个线程同时修改对象的状态,会造成数据不一致的问题。这种特性使得不可变对象天然具有线程安全性。
3. 防止重排序
final
关键字还与JVM的内存模型紧密相关。它能确保变量在构造时的初始化顺序,防止指令重排序(即,JVM或CPU为了优化性能而调整指令执行顺序的行为)。这样,可以保证在构造过程中,final
变量的值在对象构造完成之前不会发生变化,确保其他线程能够正确地看到该变量的值。
总结
final
关键字在多线程中的作用主要是:
-
保证对象构造完成后,其字段在所有线程中都是可见的;
-
使得对象不可修改,从而避免多个线程修改对象状态造成的竞争条件;
-
防止指令重排序,确保变量初始化顺序,避免可见性问题。
这些特点使得 final
成为实现线程安全和正确发布共享变量的重要工具。