Java“并发”常见问题概述
进程和线程区别?
进程是操纵系统的一个概念,用于直接分配内存运行程序的一个标识。即,每一个运行的程序。
线程是由进程创建的,一个进程可以创建多个线程,每个线程有自己独立的程序控制流程。
线程的生命周期:
**初始(NEW)**:线程被构建,还没有调用 start()。
**运行(RUNNABLE)**:包括操作系统的就绪和运行两种状态。
**阻塞(BLOCKED)**:一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。
**等待(WAITING)**:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
**超时等待(TIMED_WAITING)**:该状态不同于WAITING,它可以在指定的时间后自行返回。
**终止(TERMINATED)**:表示该线程已经执行完毕。
创建线程的方法
- 通过扩展
Thread
类来创建多线程 - 通过实现
Runnable
接口来创建多线程 - 实现
Callable
接口,通过FutureTask
接口创建线程。 - 使用
Executor
框架来创建线程池。
1 | public class CallableTest { |
线程池方式:
1 | //获取ExecutorService实例,生产禁用,需要手动创建线程池 |
线程池的作用
系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。同时频繁的创建和销毁 thread 会造成资源的浪费和失控。
创建Thread的消耗是远远大于java中的普通对象创建的,因为存在系统和jvm的原因,创建一个线程对象通常需要为其分配诸如 栈内存(栈桢等)、程序计数器 、os线程与java线程的链接 等额外的内存或空间。
使用线程池的优点在于:
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。
线程池执行流程:
- 当线程池里存活的线程数小于核心线程数
corePoolSize
时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数corePoolSize
时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime
,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。 - 当线程池里面存活的线程数已经等于corePoolSize了,这是对于一个新提交的任务,会被放进任务队列workQueue排队等待执行。
- 当线程池里面存活的线程数已经等于
corePoolSize
了,并且任务队列也满了,假设maximumPoolSize>corePoolSize
,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到maximumPoolSize
,就不会再创建了。 - 如果当前的线程数达到了
maximumPoolSize
,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。
ThreadPoolExecutor 的通用构造函数:
1 | public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler); |
1、corePoolSize
:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。
2、maximumPoolSize
:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。
3、BlockingQueue
:存储等待运行的任务。
4、keepAliveTime
:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止。
5、TimeUnit
:时间单位
1 | TimeUnit.DAYS |
6、ThreadFactory
:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。
1 | public class MyThreadFactory implements ThreadFactory { |
7、RejectedExecutionHandler
:当队列和线程池都满了的时候,根据拒绝策略处理新任务。
1 | AbortPolicy:默认的策略,直接抛出RejectedExecutionException |
线程池大小怎么设置?
如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致OOM。
如果线程池线程数量过大,大量线程可能会同时抢占 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了执行效率。
**CPU 密集型任务(N+1)**: 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1
,多出来的一个线程是为了防止某些原因导致的线程阻塞(如IO操作,线程sleep,等待锁)而带来的影响。一旦某个线程被阻塞,释放了CPU资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
**I/O 密集型任务(2N)**: 系统的大部分时间都在处理 IO 操作,此时线程可能会被阻塞,释放CPU资源,这时就可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务的应用中,可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (IO耗时/CPU耗时))
,一般可设置为2N。
线程池的类型有哪些?适用场景?
常见的线程池有 FixedThreadPool
、SingleThreadExecutor
、CachedThreadPool
和 ScheduledThreadPool
。这几个都是 ExecutorService
线程池实例。
FixedThreadPool
固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。
1 | public static ExecutorService newFixedThreadPool(int nThreads) { |
使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用RejectedExecutionHandler.rejectedExecution()方法。
maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。
keepAliveTime 也是无效参数,设置为0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了executor.allowCoreThreadTimeOut(true))。
适用场景:适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。
SingleThreadExecutor
只有一个线程的线程池。
1 | public static ExecutionService newSingleThreadExecutor() { |
使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。
适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致 OOM。
CachedThreadPool
根据需要创建新线程的线程池。
1 | public static ExecutorService newCachedThreadPool() { |
如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool
会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)
提交的任务会被空闲线程处理,否则会创建新的线程处理任务。
适用场景:用于并发执行大量短期的小任务。CachedThreadPool
允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
ScheduledThreadPoolExecutor
在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如quartz
。
使用的任务队列 DelayQueue
封装了一个 PriorityQueue
,PriorityQueue
会对队列中的任务进行排序,时间早的任务先被执行(即ScheduledFutureTask
的 time
变量小的先执行),如果time相同则先提交的任务会被先执行(ScheduledFutureTask
的 squenceNumber
变量小的先执行)。
适用场景:周期性执行任务的场景,需要限制线程数量的场景。
怎么判断线程池的任务是不是执行完了?
有几种方法:
1、使用线程池的原生函数isTerminated();
executor提供一个原生函数isTerminated()来判断线程池中的任务是否全部完成。如果全部完成返回true,否则返回false。
2、使用重入锁,维持一个公共计数。
所有的普通任务维持一个计数器,当任务完成时计数器加一(这里要加锁),当计数器的值等于任务数时,这时所有的任务已经执行完毕了。
3、使用CountDownLatch。
它的原理跟第二种方法类似,给CountDownLatch一个计数值,任务执行完毕后,调用countDown()执行计数值减一。最后执行的任务在调用方法的开始调用await()方法,这样整个任务会阻塞,直到这个计数值为零,才会继续执行。
这种方式的缺点就是需要提前知道任务的数量。
4、submit向线程池提交任务,使用Future判断任务执行状态。
使用submit向线程池提交任务与execute提交不同,submit会有Future类型的返回值。通过future.isDone()方法可以知道任务是否执行完成。
execute和submit的区别
execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。
execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常
execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。
线程死锁的产生和避免
死锁产生的四个必要条件:
- 互斥:一个资源每次只能被一个进程使用
- 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
- 不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
- 循环等待:进程之间循环等待着资源
避免死锁的方法:
- 互斥条件不能破坏,因为加锁就是为了保证互斥
- 一次性申请所有的资源,避免线程占有资源而且在等待其他资源
- 占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
- 按序申请资源
Thread 的常用方法
start
启动线程,调用start 才会分配一个系统线程给当前Thread 实例。getPriority
get线程优先级
如果不指定,则目标线程继承当前线程优先级。setPrority
设置优先级interrupt
中断线程(只是一个信号标记,告诉线程此时应该中断,具体后续操作有程序员去回应做什么,这意味着程序里我们应该自己编写中断检测逻辑.)当一个线程收到 interrupt 信号后,有2中情况:
- 当前线程正在阻塞(sleep,wait,join) ,那么当前线程直接中断,并抛出InterrupedException。
- 如果是活动状态,那么标记当前线程为中断状态,等待中断检查来结束线程.
1
2
3
4
5
6
7
8
9
10public void run() {
try {
>// 1. isInterrupted()保证,只要中断标记为true就终止线程。
while (!isInterrupted()) {
>// 执行任务...
}
} catch (InterruptedException ie) {
>// 2. InterruptedException异常保证,当InterruptedException异常产生时,线程被>终止。
>}
>}
join
加入到其他线程(阻塞当前),等待其他线程结束后,当前线程由阻塞转为就绪状态等待运行。yield
暂停当前线程,让出cpu。sleep
阻塞当前线程,时间结束后转为就绪状态。
volatile 原理?
volatile
是轻量级的同步机制,volatile
保证变量对所有线程的可见性,不保证原子性。
- 当对
volatile
变量进行写操作的时候,JVM会向处理器发送一条LOCK
前缀的指令,将该变量所在缓存行的数据写回系统内存。 - 由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。
来看看缓存一致性协议是什么。
缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。
volatile
关键字的两个作用:
- 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入
内存屏障
指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。
使用场景:
- 变量的写入操作不依赖变量的当前值,或者只有单个线程修改变量的值,但是其他线程需要立即看到修改后的值。
- 变量不会被多个线程同时修改,但是会被多个线程读取。
- 使用`volatile`关键字保证线程之间的通信,例如使用`volatile`关键字声明的标识位来控制线程的启停。
synchronized 关键字
作用:
- 原子性,被同步的代码必须保证线程互斥,在一个线程内的执行是不会分割与打断的。
- 可见性
- 有序性
底层实现
synchronized 同步代码块的实现是通过 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。当执行 monitorenter
指令时,线程试图获取锁也就是获取 monitor
的持有权(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit
指令后,将锁计数器设为0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
与volatile 区别
- volatile 保证可见性顺序性,synchronized 保证原子性和可见性
- vlo 只能用于变量;
- vol 不会阻塞线程,sync 会。
与 reentrantLock 的区别?
- 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
- synchronized是非公平锁,ReentrantLock可以设置为公平锁。
- ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
- ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
- ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。
ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程ID和当前请求的线程ID是否 一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。
源码如下:
1 | final boolean nonfairTryAcquire(int acquires) { |
wait()和sleep() 的异同?
相同点:
- 它们都可以使当前线程暂停运行,把机会交给其他线程
- 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出
InterruptedException
不同点:
wait()
是Object超类中的方法;而sleep()
是线程Thread类中的方法- 对锁的持有不同,
wait()
会释放锁,而sleep()
并不释放锁 - 唤醒方法不完全相同,
wait()
依靠notify
或者notifyAll
、中断、达到指定时间来唤醒;而sleep()
到达指定时间被唤醒 - 调用
wait()
需要先获取对象的锁,而Thread.sleep()
不用
runnable 和callable 的区别?
方法定义:
Runnable: 实现 Runnable 接口的类需要提供一个无返回值的 run() 方法。
Callable: 实现 Callable 接口的类需要提供一个有返回值的 call() 方法,该方法还可以抛出受检异常。
返回值:
Runnable: 无返回值,run() 方法执行完毕后不会有任何返回。
Callable: 有返回值,call() 方法的返回类型是泛型
异常处理:
Runnable: run() 方法不能抛出受检异常(即非 RuntimeException 的异常),如果需要抛出异常,必须捕获并封装为 RuntimeException 或不抛出。
Callable: call() 方法可以抛出任何类型的异常,包括受检异常。
与线程的关系:
Runnable: 可以直接实例化一个 Thread 并传入 Runnable 对象,然后调用 Thread.start() 启动线程。
Callable: 不能直接与 Thread 结合使用,需要通过 FutureTask 封装 Callable 对象,然后将 FutureTask 传递给 Thread,才能启动线程。
获取结果:
Runnable: 因为没有返回值,所以无法从 Runnable 的任务中获取执行结果。
Callable: 使用 FutureTask 包装 Callable 后,可以通过 FutureTask.get() 获取 Callable 任务的执行结果,这会阻塞调用线程直到任务完成。
总结来说,Runnable 更适合那些不需要返回值或抛出受检异常的简单任务,而 Callable 则适用于需要返回结果或处理异常的复杂任务。在实际开发中,根据任务的需求选择合适的接口。
线程间通信的方式?
1、使用 Object 类的 **wait()/notify()**。Object 类提供了线程间通信的方法:wait()
、notify()
、notifyAll()
,它们是多线程通信的基础。其中,wait/notify
必须配合 synchronized
使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify()
,notify并不释放锁,只是告诉调用过wait()
的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait()
的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。
2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
3、使用JUC工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent
包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch
基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport
是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。
threadLocal 如何理解?
线程本地变量。当使用ThreadLocal
维护变量时,ThreadLocal
为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。
每个线程都有一个ThreadLocalMap
(ThreadLocal
内部类),Map中元素的键为ThreadLocal
,而值对应线程的变量副本。
threadLocals
的类型ThreadLocalMap
的键为ThreadLocal
对象,因为每个线程中可有多个threadLocal
变量,如longLocal
和stringLocal
。
ThreadLocal
并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal
适合作为线程上下文变量,简化线程内传参。
threadLocal 内存泄露的问题,,由于每个线程都存在 threadlocalMap,在线程池存在的情况下,map内的entry 不会被内存释放,久而久之value会越来越大。 这里提倡的方式是,
threadLocal 使用完后请一定要执行 remove方法手动删除map值。
threadLocal 的使用场景
在线程上下文保持变量的可访问和共享。给每个线程提供一份副本。 比较常见的场景就是 java web 中的session 信息在各个整个请求执行上下文内共享信息。
什么是AQS?
AQS(AbstractQueuedSynchronizer)是java.util.concurrent包下的核心类,我们经常使用的ReentrantLock、CountDownLatch,都是基于AQS抽象同步式队列实现的。
AQS作为一个抽象类,通常是通过继承来使用的。它本身是没有同步接口的,只是定义了同步状态和同步获取和同步释放的方法。
JUC包下面大部分同步类,都是基于AQS的同步状态的获取与释放来实现的,然后AQS是个双向链表。
为什么AQS是双向链表而不是单向的?
双向链表有两个指针,一个指针指向前置节点,一个指针指向后继节点。所以,双向链表可以支持常量 O(1) 时间复杂度的情况下找到前驱节点。因此,双向链表在插入和删除操作的时候,要比单向链表简单、高效。
从双向链表的特性来看,AQS 使用双向链表有2个方面的原因:
- 没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以,线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 Head 节点开始遍历,性能非常低。
- 在 Lock 接口里面有一个lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成 CANCELLED。而被标记为 CANCELLED 状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。这就意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从 Head 节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
AQS原理
AQS,AbstractQueuedSynchronizer
,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch
。
AQS使用一个volatile
的int类型的成员变量state
来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时 ,如果 state
=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state
加1。如果 state
不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
1 | private volatile int state;//共享变量,使用volatile修饰保证线程可见性 |
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享 )构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。
锁的分类
公平锁与非公平锁
按照线程访问顺序获取对象锁。synchronized
是非公平锁,Lock
默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
1 | public ReentrantLock() { |
共享式与独占式锁
共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。
悲观锁与乐观锁
悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized
和ReentrantLock
属于悲观锁。
乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS
。
适用场景:
- 悲观锁适合写操作多的场景。
- 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
乐观锁有什么问题?
乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:
- 乐观锁只能保证一个共享变量的原子操作。
- 长时间自旋可能导致开销大。假如CAS长时间不成功而一直自旋,会给CPU带来很大的开销。
- ABA问题。CAS的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是A, 后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。
什么是CAS?
CAS全称Compare And Swap
,比较与交换,是乐观锁的主要实现方式。CAS在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock
内部的AQS和原子类内部都使用了CAS。
CAS算法涉及到三个操作数:
- 需要读写的内存值V。
- 进行比较的值A。
- 要写入的新值B。
只有当V的值等于A时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。
以AtomicInteger
为例,AtomicInteger
的getAndIncrement()
方法底层就是CAS实现,关键代码是 compareAndSwapInt(obj, offset, expect, update)
,其含义就是,如果obj
内的value
和expect
相等,就证明没有其他线程改变过这个变量,那么就更新它为update
,如果不相等,那就会继续重试直到成功更新值。
CAS存在的问题?
ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从
A-B-A
变成了1A-2B-3A
。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
常用的并发控制工具类有哪些?
CountDownLatch
CountDownLatch用于某个线程等待其他线程执行完任务再执行,与thread.join()功能类似。常见的应用场景是开启多个线程同时执行某个任务,等到所有任务执行完再执行特定操作,如汇总统计结果。
1 | public class CountDownLatchDemo { |
1 | 运行结果: |
CyclicBarrier
CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。
1 | public CyclicBarrier(int parties, Runnable barrierAction) { |
参数parties指让多少个线程或者任务等待至某个状态;参数barrierAction为当这些线程都达到某个状态时会执行的内容。
1 | public class CyclicBarrierTest { |
1 | 运行结果如下,可以看出CyclicBarrier是可以重用的: |
当四个线程都到达barrier状态后,会从四个线程中选择一个线程去执行Runnable。
CyclicBarrier和CountDownLatch区别
CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待。
CountDownLatch用于某个线程等待其他线程执行完任务再执行。CyclicBarrier用于一组线程互相等待到某个状态,然后这组线程再同时执行。 CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置,可用于处理更为复杂的业务场景。
Semaphore
Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。
1 | public class SemaphoreDemo { |
运行结果如下,可以看出并非按照线程访问顺序获取资源的锁,即
1 | worker0 using the machine |
原子类
基本类型原子类
使用原子的方式更新基本类型
- AtomicInteger:整型原子类
- AtomicLong:长整型原子类
- AtomicBoolean :布尔型原子类
AtomicInteger 类常用的方法:
1 | public final int get() //获取当前的值 |
AtomicInteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。
数组类型原子类
使用原子的方式更新数组里的某个元素
- AtomicIntegerArray:整形数组原子类
- AtomicLongArray:长整形数组原子类
- AtomicReferenceArray :引用类型数组原子类
AtomicIntegerArray 类常用方法:
1 | public final int get(int i) //获取 index=i 位置元素的值 |
引用类型原子类
- AtomicReference:引用类型原子类
- AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
- AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来
什么是Future?
在并发编程中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。
Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。
Future接口主要包括5个方法:
- get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕
- get(long timeout,TimeUnit unit)做多等待timeout的时间就会返回结果
- cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过mayInterruptIfRunning来进行判断),则可以返回true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回false。
- isDone()方法判断当前方法是否完成
- isCancel()方法判断当前方法是否取消
select、poll、epoll之间区别?
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select的时间复杂度O(n)。它仅仅知道有I/O事件发生了,却并不知道是哪那几个流,只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的时间复杂度,同时处理的流越多,轮询时间就越长。
poll的时间复杂度O(n)。poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
epoll的时间复杂度O(1)。epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动的。
ReadWriteLock 和 StampedLock 的区别
在多线程编程中,对于共享资源的访问控制是一个非常重要的问题。在并发环境下,多个线程同时访问共享资源可能会导致数据不一致的问题,因此需要一种机制来保证数据的一致性和并发性。
Java提供了多种机制来实现并发控制,其中 ReadWriteLock 和 StampedLock 是两个常用的锁类。本文将分别介绍这两个类的特性、使用场景以及示例代码。
ReadWriteLock
ReadWriteLock 是Java提供的一个接口,全类名:java.util.concurrent.locks.ReentrantReadWriteLock
。它允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。这种机制可以提高读取操作的并发性,但写入操作需要独占资源。
特性
- 多个线程可以同时获取读锁,但只有一个线程可以获取写锁。
- 当一个线程持有写锁时,其他线程无法获取读锁和写锁,读写互斥。
- 当一个线程持有读锁时,其他线程可以同时获取读锁,读读共享。
使用场景
ReadWriteLock 适用于读多写少的场景,例如缓存系统、数据库连接池等。在这些场景中,读取操作占据大部分时间,而写入操作较少。
示例代码
下面是一个使用 ReadWriteLock 的示例,实现了一个简单的缓存系统:
1 | public class Cache { |
在上述示例中,Cache 类使用 ReadWriteLock 来实现对 data 的并发访问控制。get 方法获取读锁并读取数据,put 方法获取写锁并写入数据。
StampedLock
StampedLock 是Java 8 中引入的一种新的锁机制,全类名:java.util.concurrent.locks.StampedLock
,它提供了一种乐观读的机制,可以进一步提升读取操作的并发性能。
特性
- 与 ReadWriteLock 类似,StampedLock 也支持多个线程同时获取读锁,但只允许一个线程获取写锁。
- 与 ReadWriteLock 不同的是,StampedLock 还提供了一个乐观读锁(Optimistic Read Lock),即不阻塞其他线程的写操作,但在读取完成后需要验证数据的一致性。
使用场景
StampedLock 适用于读远远大于写的场景,并且对数据的一致性要求不高,例如统计数据、监控系统等。
示例代码
下面是一个使用 StampedLock 的示例,实现了一个计数器:
1 | public class Counter { |
在上述示例中,Counter 类使用 StampedLock 来实现对计数器的并发访问控制。getCount 方法首先尝试获取乐观读锁,并读取计数器的值,然后通过 validate 方法验证数据的一致性。如果验证失败,则获取悲观读锁,并重新读取计数器的值。increment 方法获取写锁,并对计数器进行递增操作。
总结
ReadWriteLock 和 StampedLock 都是Java中用于并发控制的重要机制。
- ReadWriteLock 适用于读多写少的场景;
- StampedLock 则适用于读远远大于写的场景,并且对数据的一致性要求不高;
在实际应用中,我们需要根据具体场景来选择合适的锁机制。通过合理使用这些锁机制,我们可以提高并发程序的性能和可靠性。