基础知识
线程的状态
线程的生命周期分为五种状态:
- 新生
NEW
- 运行
RUNNABLE
- 阻塞
BLOCKED
- 等待
WAITING
- 超时等待
TIME_WAITING
- 终止
TERMINATED
在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态
为什么 JVM 没有区分这两种状态呢?
现在的时分多任务操作系统架构通常都是用所谓的“时间分片”方式进行抢占式轮转调度。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。
wait/sleep
区别:
sleep()
** 方法没有释放锁,而wait()
方法释放了锁** 。这是因为sleep()
是Thread
类的静态本地方法,不会操作对象锁,而wait()
则是Object
类的本地方法,让获得对象锁的线程实现等待wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。
因此,wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停当前线程的执行。
为什么 wait() 方法不定义在 Thread 中?
wait()
是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入等待状态,自然是要操作对应的对象而非当前的线程。
Synchronized
公平锁: 十分公平,必须先来后到;
非公平锁: 十分不公平,可以插队;**(默认)**
Lock锁
lock三部曲
1 | 1. Lock lock=new ReentrantLock(); |
Synchronized锁 与Lock锁 的区别
- Synchronized 无法判断获取锁的状态,Lock可以判断
- Synchronized 会自动释放锁,lock必须要手动加锁和手动释放锁!可能会遇到死锁
- Synchronized 线程1(获得锁->阻塞)、线程2(等待);lock就不一定会一直等待下去,lock会有一个trylock去尝试获取锁,不会造成长久的等待。
- Synchronized 是可重入锁,不可以中断的,非公平的;Lock是可重入的,可以判断锁,可以自己设置公平锁和非公平锁;
- Synchronized 适合锁少量的代码同步问题,Lock适合锁大量的同步代码;
Callable接口
实现类:FutureTask
,可以获取到线程执行完毕的结果
Callable
与Runable
的区别:
Callable
可以有返回值Callable
可以抛出异常,而Runnable
不能抛出被检查的异常- 启动方法不同
线程安全类集合
List类
Vector
Collections.synchronizedList()
CopyOnWriteArrayList
适用于读多写少的场景
核心思想是:如果有多个调用者同时要求相同的资源,他们会共同获取相同的指针指向相同的资源,直到某个调用者视图修改资源内容时,系统才会真正复制一份专用副本给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。
**CopyOnWriteArrayList
比Vector
**区别?
- **
Vector
底层是使用synchronized
**关键字来实现的,效率低下 - **
CopyOnWriteArrayList
**使用的是Lock锁,更加高效
Set类
Collections.synchronizedSet()
CopyOnWriteArraySet
Map类
Collections.synchronizedMap()
ConcurrentHashMap
HashTable
线程通信
生产者和消费者问题
这是一个经典的线程通信问题。两组线程共享一个缓冲区。生产者将数据放入缓冲区,消费者将数据从缓冲区取出
demo如下:
1 | class ProviderConsumer{ |
虚假唤醒问题
当一定的条件触发时会唤醒很多在阻塞态的线程,但只有部分的线程唤醒是有用的,其余线程的唤醒是多余的。
解决虚假唤醒问题:应该将唤醒放在循环中,不满足条件需要继续等待
换句话说:将if替换成while。当使用notifyAll()
时,所有的线程都将被唤醒,如果使用的是if,不会再次进行条件判断,因此被唤醒的可能是消费者,也可能是生产者。而使用while的时候,会再次进行等待判断,从而避免虚假唤醒问题。
Lock + Condition实现
Lock锁的Condition可以精准通知唤醒的线程,从而控制多个线程的执行顺序
demo:多个线程轮流输出A B C
1 | class TestCondition { |
BlockingQueue实现生产者消费者问题
BlockingQueue是Java自带的阻塞队列,内部的原理也是使用了ReentrantLock + Condition实现
1 | public static void main(String[] args) { |
Java中的线程协作
CountDownLatch
倒计数锁存器,可以用作一个简单的开/关锁存器,或者门:所有线程调用await()
在门口等待,直到被调用countDown()
的线程打开。
CountDownLatch一个有用的属性是,它不要求调用countDown线程等待计数到达零之前相互等待,它只是阻止任何线程通过await,直到所有线程可以通过。
常用方法:
- **
countDown()
**减一操作; await()
等待计数器归零
CyclickBarrier
循环屏障,它允许一组线程全部等待彼此达到共同屏障点的同步辅助。循环阻塞在涉及固定大小的线程方的程序中很有用,这些线程必须偶尔等待彼此。屏障被称为循环,因为它可以在等待的线程被释放之后重新使用。
常用方法包括:
await()
: 调用该方法的线程到达屏障点,并等待其他线程到达。如果是最后一个到达的线程,将执行可选的任务。await(long timeout, TimeUnit unit)
: 调用该方法的线程到达屏障点,并等待其他线程到达,但最多等待指定的时间。getParties()
: 返回需要到达屏障点的总线程数。isBroken()
: 检查屏障是否被破坏(是否有线程等待超时)。
**CyclicBarrier
****与 ****CountDownLatch
**区别
CountDownLatch
是一次性的,CyclicBarrier
是可以重用的CountDownLatch
中有两个关键,一个是countDown()
,一个是awit()
,调用awit()
的线程需要等待,调用countDown()
的线程会将倒计时-1,不同的线程职责可能是不同的,被管理的是调用了awit()
的线程,计数器可以是单独的逻辑。
Semaphore
信号量,用于控制同时访问某个资源的线程数量。它可以用来限制并发访问的线程数,或者用于线程间的信号通知。
Semaphore
维护了一组许可(permits),线程在访问资源之前必须先获得许可,如果许可数不足,则线程必须等待,直到有可用的许可为止。每个 Semaphore
对象都有一个初始许可数,表示可同时访问该资源的线程数。
假设信号量初始数量为sem:
P 操作:申请一个许可,将 sem 减 1,相减后,如果 sem < 0
,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
V 操作:释放一个许可,将 sem 加 1,相加后,如果 sem <= 0
,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
常用方法:
acquire()
: 获取一个许可,如果没有可用许可,则线程会被阻塞,直到有可用许可。release()
: 释放一个许可,将其返回给Semaphore
。tryAcquire()
: 尝试获取一个许可,如果获取成功则返回 true,否则返回 false。availablePermits()
: 返回当前可用的许可数。
ReadWriteLock
读写锁,控制对共享资源访问的同步机制。它允许多个线程同时读取资源,但只允许一个线程独占写入资源。
ReadWriteLock的目的是在资源被读取的频率高于写入的情况下优化性能。通过允许并发读取,多个线程可以同时访问资源,这可以提高吞吐量并减少线程之间的竞争。然而,当一个线程希望修改资源时,它需要独占访问以确保一致性。
Fork/Join
核心思想:分而治之
Fork-Join模型的核心思想是关键概念是”fork”(分叉)和”join”(合并),即将一个大任务划分为若干个小任务,然后并行地执行这些小任务,最后将它们的结果组合起来得到最终的结果。这个过程可以递归地进行,即每个小任务也可以再次划分成更小的子任务,直到任务的规模足够小以至于可以被直接执行。
两个实现类:
RecursiveTask
有返回值RecursiveAction
没有返回值
Fork/Join框架的优势在于它能够自动地将任务划分成合适的大小,并利用多核处理器上的并行性提高程序的性能。它也提供了一些优化技术,如工作窃取(work stealing),可以确保各个线程在执行任务时能够充分利用系统资源。
工作窃取原理
- 每个工作线程都有一个本地的工作队列(双端队列),用于存储待执行的任务。使用双端队列作为本地工作队列的好处在于,工作线程可以高效地从队列的头部或尾部执行插入和删除操作。当工作线程执行任务时,它会从队列的头部获取任务并执行;而当工作线程尝试窃取任务时,它会从队列的尾部插入窃取到的任务。
- 当一个工作线程的本地工作队列为空时,它会尝试从其他工作线程的工作队列中窃取任务。这个窃取的目标通常是选择一个相对较繁忙的工作线程,即其工作队列中有更多任务等待执行。
- 工作线程可以从目标工作线程的工作队列的顶部(头部)或者底部(尾部)窃取任务。选择窃取的位置可以根据具体的实现策略来确定,不同的实现方式可能有所不同。
- 窃取任务的过程通常是通过线程间的原子操作来实现的,以确保并发的正确性。例如,可以使用CAS(Compare and Swap)操作来保证任务的窃取是原子的。
- 当一个工作线程成功地窃取到任务后,它会将任务添加到自己的本地工作队列中,并继续执行窃取到的任务。