Java并发
进程线程
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
何为线程?
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
线程的生命周期
初始(NEW):线程被构建,还没有调用 start()。
运行(RUNNABLE):包括操作系统的就绪和运行两种状态。
阻塞(BLOCKED):一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
终止(TERMINATED):表示该线程已经执行完毕。
并发与并行的区别?
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
为什么要使用多线程呢?
先从总体上来说:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代: 在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
什么是上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了
sleep()
,wait()
等。 - 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
讲讲线程中断?
线程中断即线程运行过程中被其他线程给打断了,它与 stop 最大的区别是:stop 是由系统强制终止线程,而线程中断则是给目标线程发送一个中断信号,如果目标线程没有接收线程中断的信号并结束线程,线程则不会终止,具体是否退出或者执行其他逻辑取决于目标线程。
线程中断三个重要的方法:
1、java.lang.Thread#interrupt
调用目标线程的interrupt()
方法,给目标线程发一个中断信号,线程被打上中断标记。
2、java.lang.Thread#isInterrupted()
判断目标线程是否被中断,不会清除中断标记。
3、java.lang.Thread#interrupted
判断目标线程是否被中断,会清除中断标记。
1 | private static void test2() { |
创建线程有哪几种方式?
- 通过扩展
Thread
类来创建多线程 - 通过实现
Runnable
接口来创建多线程 - 实现
Callable
接口,通过FutureTask
接口创建线程。 - 使用
Executor
框架来创建线程池。
继承 Thread 创建线程代码如下。run()方法是由jvm创建完操作系统级线程后回调的方法,不可以手动调用,手动调用相当于调用普通方法。
1 | /** |
Runnable 创建线程代码:
1 | /** |
实现Runnable接口比继承Thread类所具有的优势:
- 可以避免java中的单继承的限制
- 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类
Callable 创建线程代码:
1 | /** |
使用 Executor 创建线程代码:
1 | /** |
Runnable和Callable有什么区别?
- Callable接口方法是
call()
,Runnable的方法是run()
; - Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
- Callable接口
call()
方法允许抛出异常;而Runnable接口run()
方法不能继续上抛异常。
执行 execute()方法和 submit()方法的区别是什么呢?
execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()
方法用于提交需要返回值的任务。线程池会返回一个Future
类型的对象,通过这个Future
对象可以判断任务是否执行成功,并且可以通过Future
的get()
方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
什么是线程死锁?
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
线程死锁怎么产生?怎么避免?
死锁产生的四个必要条件:
- 互斥:一个资源每次只能被一个进程使用
- 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
- 不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
- 循环等待:进程之间循环等待着资源
避免死锁的方法:
- 互斥条件不能破坏,因为加锁就是为了保证互斥
- 一次性申请所有的资源,避免线程占有资源而且在等待其他资源
- 占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
- 按序申请资源
说说 sleep() 方法和 wait() 方法区别和共同点?
- 两者最主要的区别在于:**
sleep()
方法没有释放锁,而wait()
方法释放了锁** 。 - 两者都可以暂停线程的执行。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。
线程run和start的区别?
- 当程序调用
start()
方法,将会创建一个新线程去执行run()
方法中的代码。run()
就像一个普通方法一样,直接调用run()
的话,不会创建新线程。 - 一个线程的
start()
方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException 异常。run()
方法则没有限制。
线程池
线程池:一个管理线程的池子。
为什么使用线程池?
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。
线程池执行原理?
- 当线程池里存活的线程数小于核心线程数
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
:时间单位
6、ThreadFactory
:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。
1 | public class MyThreadFactory implements ThreadFactory { |
7、RejectedExecutionHandler
:当队列和线程池都满了的时候,根据拒绝策略处理新任务。
AbortPolicy:默认的策略,直接抛出RejectedExecutionException
DiscardPolicy:不处理,直接丢弃
DiscardOldestPolicy:将等待队列队首的任务丢弃,并执行当前任务
CallerRunsPolicy:由调用线程处理该任务
线程池大小怎么设置?
如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致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
变量小的先执行)。
执行周期任务步骤:
- 线程从
DelayQueue
中获取已到期的ScheduledFutureTask(DelayQueue.take())
。到期任务是指ScheduledFutureTask
的 time 大于等于当前系统的时间; - 执行这个
ScheduledFutureTask
; - 修改
ScheduledFutureTask
的 time 变量为下次将要被执行的时间; - 把这个修改 time 之后的
ScheduledFutureTask
放回DelayQueue
中(DelayQueue.add()
)。
适用场景:周期性执行任务的场景,需要限制线程数量的场景。
synchronized 关键字
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
synchronized的用法有哪些?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
synchronized的作用有哪些?
原子性:确保线程互斥的访问同步代码;
可见性:保证共享变量的修改能够及时可见;
有序性:有效解决重排序问题。
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
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
ReentrantLock和synchronized区别
- 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
- synchronized是非公平锁,ReentrantLock可以设置为公平锁。
- ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
- ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
- ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。
volatile 关键字
volatile
是轻量级的同步机制,volatile
保证变量对所有线程的可见性,不保证原子性。
- 当对
volatile
变量进行写操作的时候,JVM会向处理器发送一条LOCK
前缀的指令,将该变量所在缓存行的数据写回系统内存。 - 由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。
来看看缓存一致性协议是什么。
缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。
volatile
关键字的两个作用:
- 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入
内存屏障
指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。
volatile和synchronized的区别是什么?
volatile
只能使用在变量上;而synchronized
可以在类,变量,方法和代码块上。volatile
至保证可见性;synchronized
保证原子性与可见性。volatile
禁用指令重排序;synchronized
不会。volatile
不会造成阻塞;synchronized
会。
ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程ID和当前请求的线程ID是否 一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。
源码如下:
1 | final boolean nonfairTryAcquire(int acquires) { |
ThreadLocal
ThreadLocal原理
线程本地变量。当使用ThreadLocal
维护变量时,ThreadLocal
为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。

调用threadLocal.set()
–>调用getMap(Thread)
–>返回当前线程的ThreadLocalMap<ThreadLocal, value>
–>map.set(this, value)
,this是threadLocal
本身。源码如下:
1 | public void set(T value) { |
调用get()
–>调用getMap(Thread)
–>返回当前线程的ThreadLocalMap<ThreadLocal, value>
–>map.getEntry(this)
,返回value
。源码如下:
1 | public T get() { |
threadLocals
的类型ThreadLocalMap
的键为ThreadLocal
对象,因为每个线程中可有多个threadLocal
变量,如longLocal
和stringLocal
。
1 | public class ThreadLocalDemo { |
ThreadLocal
并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal
适合作为线程上下文变量,简化线程内传参。
ThreadLocal内存泄漏的原因?
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后 最好手动调用remove()
方法
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
ThreadLocal使用场景有哪些?
ThreadLocal
适用场景:每个线程需要有自己单独的实例,且需要在多个方法中共享实例,即同时满足实例在线程间的隔离与方法间的共享,这种情况适合使用ThreadLocal
。比如Java web应用中,每个线程有自己单独的Session
实例,就可以使用ThreadLocal
来实现。
AQS
AQS 原理
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出大量应用广泛的同步器,比如我们提到的 ReentrantLock
,Semaphore
,其他的诸如 ReentrantReadWriteLock
,SynchronousQueue
,FutureTask
等等皆是基于 AQS 的。
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
AQS使用一个volatile
的int类型的成员变量state
来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时 ,如果 state
=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state
加1。如果 state
不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
1 | private volatile int state;//共享变量,使用volatile修饰保证线程可见性 |
AQS 对资源的共享方式
- Exclusive(独占):只有一个线程能执行,如
ReentrantLock
。又可分为公平锁和非公平锁:- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如
CountDownLatch
、Semaphore
、CyclicBarrier
、ReadWriteLock
我们都会在后面讲到。
ReentrantReadWriteLock
可以看成是组合式,因为 ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。
锁的分类
公平锁与非公平锁
按照线程访问顺序获取对象锁。synchronized
是非公平锁,Lock
默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
1 | public ReentrantLock() { |
共享式与独占式锁
共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。
悲观锁与乐观锁
悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized
和ReentrantLock
属于悲观锁。
乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS
。
适用场景:
- 悲观锁适合写操作多的场景。
- 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
乐观锁有什么问题?
乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:
- 乐观锁只能保证一个共享变量的原子操作。
- 长时间自旋可能导致开销大。假如CAS长时间不成功而一直自旋,会给CPU带来很大的开销。
- ABA问题。CAS的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是A, 后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。
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操作。
并发工具
在JDK的并发包里提供了几个非常有用的并发工具类。CountDownLatch、CyclicBarrier和Semaphore工具类提供了一种并发流程控制的手段。
CountDownLatch
CountDownLatch
的作用就是允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch
。具体场景是下面这样的:
我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。
为此我们定义了一个线程池和 count 为 6 的CountDownLatch
对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch
对象的 await()
方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
1 | public class CountDownLatchExample1 { |
CyclicBarrier
CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。
1 | public CyclicBarrier(int parties, Runnable barrierAction) { |
参数parties指让多少个线程或者任务等待至某个状态;参数barrierAction为当这些线程都达到某个状态时会执行的内容。
1 | public class CyclicBarrierTest { |
CyclicBarrier和CountDownLatch区别
CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待。
CountDownLatch用于某个线程等待其他线程执行完任务再执行。CyclicBarrier用于一组线程互相等待到某个状态,然后这组线程再同时执行。 CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置,可用于处理更为复杂的业务场景。
Semaphore
Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。
1 | public class SemaphoreDemo { |
Atomic 原子类
基本类型原子类
使用原子的方式更新基本类型
- 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 标记与引用关联起来
参考文章: