Java并发编程
Java并发编程基础
Synchronized的作用范围
- 修饰实例方法:对当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
- 修饰静态方法:对当前类加锁,会作用于类的所有对象实例。因为静态成员不属于任何一个实例对象,是类成员。
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
总结:synchronized可以锁住类,也可以锁住类的某个对象,二者相互独立,不冲突。
深入理解volatile关键字
知识预备:可见性和原子性
- 原子性:一次只允许一个线程持有某个特定的锁,一次就只有一个线程能够使用共享数据。
- 可见性:必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。
volatile的特性
- 保证可见性:一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 不能保证原子性:不能保证
volatile变量复合操作的原子性,因为同一变量可以有多个线程进行修改。
示例代码:体现volatile的可见性
1 | |
指令重排序
指令重排序可以优化代码的执行顺序,但不能改变变量的最终结果。例如,对一个变量的两次写操作的相对位置不能改变,否则会导致最终结果发生改变。
单例模式双重检查失效问题
new关键字创建对象不是原子操作,创建一个对象会经历以下步骤:
- 在堆内存开辟内存空间。
- 调用构造方法,初始化对象。
- 引用变量指向堆内存空间。
为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。经过指令重排序之后,创建对象的执行顺序可能为1 -> 2 -> 3或者1 -> 3 -> 2。因此,当某个线程在乱序运行1 -> 3 -> 2指令的时候,引用变量指向堆内存空间,这个对象不为null,但是没有初始化,其他线程有可能这个时候进入了getInstance的第一个if (instance == null)判断,导致错误地使用了没有初始化的非null实例,这就是著名的DCL失效问题。
当我们在引用变量上添加volatile关键字以后,会通过在创建对象指令的前后添加内存屏障来禁止指令重排序,从而避免这个问题,而且对volatile修饰的变量的修改对其他任何线程都是可见的。
ThreadLocal学习
ThreadLocal简介
ThreadLocal是一个线程的“本地变量”,这种变量在多线程环境下访问(通过get和set方法访问)时能够保证各个线程的变量相对独立于其他线程内的变量,不同线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。
set方法
通过当前线程对象thread获取该thread所维护的ThreadLocalMap,如果ThreadLocalMap不为null,则以ThreadLocal实例为key,值为value的键值对存入ThreadLocalMap;若ThreadLocalMap为null的话,就新建ThreadLocalMap,然后再以ThreadLocal为键,值为value的键值对存入即可。
get方法
通过当前线程thread实例获取到它所维护的ThreadLocalMap,然后以当前ThreadLocal实例为key获取该map中的键值对(Entry)。如果Entry不为null则返回Entry的value。如果获取ThreadLocalMap为null或者Entry为null的话,就以当前ThreadLocal为Key,value为null存入map后,并返回null。
使用实例
1 | |
ThreadLocal的内部实现
创建static修饰的ThreadLocal对象于运行线程的类中,线程Thread t维护一个属性:
1 | |
它是一个HashMap,里面的Entry的结构为(key, value) —> (线程id,值)。
一个线程所在的类可以有多个ThreadLocal对象,每个threadLocal对象都会在线程维护的threadLocals中以键的形式存在。
ReentrantLock和AQS
什么是AQS?
AQS中使用的是CLH变体队列。
CLH队列
- CLH队列:是单向链表实现的队列。申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱节点释放了锁就结束自旋。
- 特性:
- CLH队列是一个单向链表,保持FIFO先进先出的队列特性。
- 通过
tail尾节点(原子引用)来构建队列,总是指向最后一个节点。 - 未获得锁节点会进行自旋,而不是切换线程状态。
- 并发高时性能较差,因为未获得锁节点不断轮询前驱节点的状态来查看是否获得锁。
AQS中的CLH变体队列
- AQS队列:是一个双向链表,也是FIFO先进先出的特性。
- 特性:
- 通过
head和tail头尾两个节点来组成队列结构,通过volatile修饰保证可见性。 head指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程。- 获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于CLH队列性能较好。
- 通过
ReentrantLock的实现
当线程获取锁失败时,ReentrantLock首先再tryAcquire()一下,tryAcquire失败,则AQS会将当前线程以及等待状态等信息构造成为一个节点(Node对象)并将其加入AQS中,同时会阻塞当前线程。
条件队列与阻塞队列
- 条件队列和阻塞队列的节点:都是
Node的实例,因为条件队列的节点是需要转移到阻塞队列中去的。 - Condition的实现:
- 每个
ReentrantLock实例可以通过多次调用newCondition()产生多个Condition实例。 - 每个
condition有一个关联的条件队列,如线程调用condition1.await()方法即可将当前线程包装成Node后加入到条件队列中,然后阻塞在这里。 - 调用
condition1.signal()触发一次唤醒,此时唤醒的是队头,会将condition1对应的条件队列的firstWaiter(队头)移到阻塞队列的队尾,等待获取锁,获取锁后await方法才能返回,继续往下执行。
- 每个
线程池
线程池参数
1 | |
向线程池中添加任务
通过ThreadPoolExecutor.execute(Runnable command)方法,即可向线程池内添加一个任务。
关闭线程池
- **
shutdown()**:执行后停止接受新任务,但会把队列的任务执行完毕。 - **
shutdownNow()**:执行后停止接受新任务,但会中断所有的任务(不管是否正在执行中),将线程池状态变为STOP状态。
拒绝策略
- AbortPolicy:拒绝任务时抛出
RejectedExecutionException异常。 - DiscardPolicy:直接丢弃任务,不通知。
- DiscardOldestPolicy:丢弃队列中存活时间最长的任务,为新任务腾出空间。
- CallerRunsPolicy:将任务交由提交任务的线程执行。
读书笔记整理
Java线程实现/创建方式
- 实现
Runnable接口中的run方法:然后把实现run方法的对象实例传入Thread类中。 - 继承
Thread类:继承Thread类,重写run方法。 - 线程池创建线程:线程池创建线程源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20static class DefaultThreadFactory implements ThreadFactory {
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
} - 有返回值的
Callable创建线程:1
2
3
4
5
6
7
8
9
10class CallableTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return new Random().nextInt();
}
}
// 创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
// 提交任务,并用Future提交返回结果
Future<Integer> future = service.submit(new CallableTask());
为什么实现Runnable接口比继承Thread类实现线程要好?
- 代码架构:实现了
Runnable与Thread类的解耦,Thread类负责线程启动和属性设置等内容,权责分明。 - 性能:使用实现
Runnable接口的方式,可以将任务直接传入线程池,使用一些固定的线程来完成任务,不需要每次新建销毁线程,大大降低了性能开销。 - 类的拓展:Java语言不支持双继承,如果我们的类一旦继承了
Thread类,那么它后续就没有办法再继承其他的类,不方便类的拓展。
如何正确停止线程
使用
interrupt停止线程:void interrupt():向线程发送中断请求,线程的中断状态将被设置为true。static boolean interrupted():测试当前线程是否被中断——静态方法——会将当前线程的中断状态重置为false。boolean isInterrupted():测试当前线程是否被中断——不会改变线程的中断状态。- 示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class StopThread implements Runnable {
@Override
public void run() {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count < 1000) {
System.out.println("count = " + count++);
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new StopThread());
thread.start();
Thread.sleep(5);
thread.interrupt();
}
}
sleep期间能否感受到中断?- 如果线程处于
sleep、wait等阻塞状态,且被中断,那么线程是可以感受到中断信号的,并且会抛出InterruptedException异常,同时清除中断信号,将中断标记位设置成false。 - 如果我们想让线程的调用者察觉到这种情况的发生,可以通过以下两种方式:
- **
catch InterruptedException异常后,将中断状态再次设置为true**(不推荐,耦合度高)。 throw InterruptedException异常即可,调用者再try-catch即可捕获异常。
- **
- 如果线程处于
为什么用
volatile标记位的停止方法是错误的?volatile这种方法在某些特殊的情况下,比如线程被长时间阻塞的情况,就无法及时感受中断,所以volatile是不够全面的停止线程的方法。- 线程被长时间阻塞:因为你是根据
volatile变量的值的情况去判断是否停止进程,但是如果你在哪阻塞了,即使那个变量改变了也没用,volatile标记位在程序运行到对该变量进行判断的语句时才对线程产生影响。
线程是如何在6种状态之间转换的?
线程可以有以下6种状态:
- New(新建)
- Runnable(可运行)
- Blocked(阻塞)
- Waiting(等待)
- Timed Waiting(计时等待)
- Terminated(终止)
New
表示线程被创建但尚未启动的状态。当我们用new Thread()新建一个线程时,如果线程没有开始运行start()方法,所以也没有开始执行run()方法里面的代码,那么此时它的状态就是New。而一旦线程调用了start(),它的状态就会从New变成Runnable。
Runnable
Java中的Runnable状态对应操作系统线程状态中的两种状态,分别是Running和Ready,也就是说,Java中处于Runnable状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配CPU资源。
Blocked
从Runnable状态进入Blocked状态只有一种可能,就是进入synchronized保护的代码时没有抢到monitor锁,无论是进入synchronized代码块,还是synchronized方法,都是一样。
Waiting
线程进入Waiting状态有以下三种可能性:
- 没有设置
Timeout参数的Object.wait()方法。 - 没有设置
Timeout参数的Thread.join()方法。 LockSupport.park()方法。
Timed Waiting
线程进入Timed Waiting状态的情况:
- 设置了时间参数的
Thread.sleep(long millis)方法。 - 设置了时间参数的
Object.wait(long timeout)方法。 - 设置了时间参数的
Thread.join(long millis)方法。 - 设置了时间参数的
LockSupport.parkNanos(long nanos)方法和LockSupport.parkUntil(long deadline)方法。
Terminated
run()方法执行完毕,线程正常退出。- 出现一个没有捕获的异常,终止了
run()方法,最终导致意外终止。
线程状态转换
- Blocked状态进入Runnable状态:要求线程获取
monitor锁。 - Waiting状态流转到其他状态:
- 如果执行了
LockSupport.unpark(),或者join的线程运行结束,或者被中断时,可以进入Runnable状态。 - 如果其他线程调用
notify()或notifyAll()来唤醒它,它会直接进入Blocked状态,因为唤醒Waiting线程的线程如果调用notify()或notifyAll(),要求必须首先持有该monitor锁,所以处于Waiting状态的线程被唤醒时拿不到该锁,就会进入Blocked状态,直到执行了notify()/notifyAll()的唤醒它的线程执行完毕并释放monitor锁,才可能轮到它去抢夺这把锁,如果它能抢到,就会从Blocked状态回到Runnable状态。
- 如果执行了
- Timed Waiting状态同理:只不过是在规定时间范围内与
Waiting相同。
wait、notify、notifyAll方法的使用注意事项
- 为什么
wait必须在synchronized保护的同步代码中使用?- 如果
notify方法在buffer.isEmpty()和wait方法之间被调用,程序就会一直被wait而不会被唤醒。
- 如果
- 为什么
wait/notify/notifyAll被定义在Object类中,而sleep定义在Thread类中?- 每个对象都有一把
monitor监视器锁,wait/notify/notifyAll是锁级别的操作,它们的锁属于对象,所以把它们定义在Object类中是最合适。
- 每个对象都有一把
wait/notify和sleep方法的异同?- 相同点:
- 它们都可以让线程阻塞。
- 它们都可以响应
interrupt中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出InterruptedException异常。
- 不同点:
wait方法必须在synchronized保护的代码中使用,而sleep方法并没有这个要求。- 在同步代码中执行
sleep方法时,并不会释放monitor锁,但执行wait方法时会主动释放monitor锁。 sleep方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的wait方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。wait/notify是Object类的方法,而sleep是Thread类的方法。
- 相同点:
三类线程安全问题
- 运行结果错误:多线程同时操作一个变量导致的运行结果错误。
- 发布和初始化导致线程安全问题:
- 示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class WrongInit {
private Map<Integer, String> students;
public WrongInit() {
new Thread(new Runnable() {
@Override
public void run() {
students = new HashMap<>();
students.put(1, "王小美");
students.put(2, "钱二宝");
students.put(3, "周三");
students.put(4, "赵四");
}
}).start();
}
public Map<Integer, String> getStudents() {
return students;
}
public static void main(String[] args) throws InterruptedException {
WrongInit multiThreadsError6 = new WrongInit();
System.out.println(multiThreadsError6.getStudents().get(1));
}
} - 上述代码中创建
multiThreadsError6对象后立刻就试图输出student的信息,而实际上此时线程可能还没启动完毕,导致空指针异常。
- 示例代码:
- 活跃性问题:
- 死锁:多个线程互相等待对方持有的资源,导致程序无法继续执行。
- 活锁:线程一直处于忙碌状态,但程序始终得不到结果。例如,一个消息队列中某个消息由于自身被写错导致不能被正确处理,队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次都无法被正确处理,最终导致线程一直处于忙碌状态,但程序始终得不到结果。
- 饥饿:线程需要某些资源时始终得不到,尤其是CPU资源,就会导致线程一直不能运行而产生的问题。例如,线程优先级过低,或者某个线程始终持有某个文件的锁,其他线程无法获取资源。
哪些场景需要额外注意线程安全问题?
- 访问共享变量或资源:例如访问共享对象的属性、访问
static静态变量、访问共享的缓存等。 - 依赖时序的操作:例如多个线程同时访问以下代码:
1
2
3if (x == 1) {
x = 7 * x;
} - 不同数据之间存在绑定关系:例如IP和端口号,需要同时更新。
- 对方没有声明自己是线程安全的:例如
ArrayList,需要在外部手动用synchronized等方式保证并发安全。
为什么多线程会带来性能问题?
- 调度开销:
- 上下文切换:线程数往往大于CPU核心数,操作系统会按照调度算法给每个线程分配时间片,线程调度会引起上下文切换,上下文切换的开销较大。
- 缓存失效:线程调度后,CPU需要重新缓存新线程的数据,这也会带来一定的开销。
- 协作开销:
- 为了保证线程安全,需要禁止编译器和CPU对共享数据进行重排序等优化,还需要频繁地将线程工作内存的数据刷新到主存中,然后再从主存刷新到其他线程的工作内存中,这会降低性能。
使用线程池比手动创建线程好在哪里?
- 解决线程生命周期的系统开销问题:线程池用一些固定的线程一直保持工作状态并反复执行任务。
- 统筹内存和CPU的使用:线程池会根据配置和任务数量灵活地控制线程数量,避免线程过多导致内存溢出,或线程太少导致CPU资源浪费。
- 统一管理资源:线程池可以统一管理任务队列和线程,可以统一开始或结束任务,便于数据统计。
线程池的各个参数的含义
- **
corePoolSize**:核心线程数,线程池初始化时线程数默认为0,当有新的任务提交后,会创建新线程执行任务,此后线程数通常不会再小于corePoolSize。 - **
maximumPoolSize**:最大线程数,当任务队列满了之后,线程池会进一步创建新线程,最多可以达到maximumPoolSize。 keepAliveTime+ 时间单位:当线程池中线程数量多于核心线程数时,而此时又没有任务可做,线程池就会检测线程的keepAliveTime,如果超过规定的时间,无事可做的线程就会被销毁。- **
ThreadFactory**:线程工厂,用于创建线程。可以选择使用默认的线程工厂,也可以选择自己定制线程工厂,以方便给线程自定义命名。 - **
workQueue**:任务的阻塞队列,用于存储提交的任务。 - **
handler**:拒绝策略,当线程池无法处理新任务时,会根据拒绝策略进行处理。
线程池有哪4种拒绝策略?
- **
AbortPolicy**:拒绝任务时抛出RejectedExecutionException异常。 - **
DiscardPolicy**:直接丢弃任务,不通知。 - **
DiscardOldestPolicy**:丢弃队列中存活时间最长的任务,为新任务腾出空间。 - **
CallerRunsPolicy**:将任务交由提交任务的线程执行。
有哪6种常见的线程池?什么是Java 8的ForkJoinPool?
- **
FixedThreadPool**:核心线程数和最大线程数相同。 - **
CachedThreadPool**:线程数可以无限增加,但空闲线程会被回收。 - **
ScheduledThreadPool**:支持定时或周期性执行任务。 - **
SingleThreadExecutor**:使用唯一线程执行任务。 - **
SingleThreadScheduledExecutor**:ScheduledThreadPool的核心线程数设置为1。 - **
ForkJoinPool**:适合递归场景,每个线程都有自己的双端队列来存储分裂出来的子任务。
线程池常用的阻塞队列有哪些?
- **
LinkedBlockingQueue**:对应FixedThreadPool和SingleThreadExecutor,容量为Integer.MAX_VALUE,可以认为是无界队列。 - **
SynchronousQueue**:对应CachedThreadPool,队列容量为0,实际不存储任何任务,只负责对任务进行中转和传递。 - **
DelayedWorkQueue**:对应ScheduledThreadPool和SingleThreadScheduledExecutor,内部元素按照延迟时间长短排序。
为什么不应该自动创建线程池?
因为上述的线程池中,要么线程没有约束,可以无限多;要么队列没有约束,可以无限大。当面对难于处理的大量任务时:
- 线程创建得太多,会导致超过操作系统的上限而无法创建新线程,或者导致内存不足。
- 队列中堆积的任务太多,会导致大量堆积的任务占用大量内存,并发生
OOM(OutOfMemoryError),这几乎会影响到整个程序,会造成很严重的后果。
合适的线程数量是多少?CPU核心数和线程数的关系?
调整线程池中的线程数量的主要目的是为了充分并合理地使用CPU和内存等资源,从而最大限度地提高程序的性能。
- CPU密集型任务:最佳的线程数为CPU核心数的1~2倍。如果设置过多的线程数,会导致上下文切换增加,性能下降。
- IO密集型任务:线程数可以大于CPU核心数很多倍,因为IO操作较慢,需要更多的线程来充分利用CPU资源。
如何根据实际需要,定制自己的线程池?
- 核心线程数:线程的平均工作时间所占比例越高,就需要越少的线程;线程的平均等待时间所占比例越高,就需要越多的线程。
- 最大线程数:如果任务类型不固定,可以设置为
corePoolSize的几倍。更好的办法是用不同的线程池执行不同类型的任务。 - 阻塞队列:可以选择
ArrayBlockingQueue等有限容量的队列,防止资源耗尽。 - 线程工厂:可以传入自定义的线程工厂,以便根据业务信息进行命名,方便后续定位问题代码。
- 拒绝策略:可以通过实现
RejectedExecutionHandler接口来实现自己的拒绝策略。
如何正确地关闭线程池?shutdown和shutdownNow的区别?
- **
shutdown()**:安全地关闭线程池,线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭。但调用shutdown()后,如果还有新的任务被提交,线程池会根据拒绝策略拒绝后续新提交的任务。 - **
isShutdown()**:返回true或false,判断线程池是否已经开始关闭流程。 - **
isTerminated()**:判断线程池是否真正“终结”,即线程池已关闭并且所有任务都执行完毕。 - **
awaitTermination()**:尝试等待一段指定的时间,直到线程池“终结”。如果在等待时间内线程池已关闭并且任务都执行完毕,返回true;否则返回false。 - **
shutdownNow()**:尝试中断所有正在执行的任务,并返回任务队列中尚未执行的任务列表。线程池会尝试中断所有线程,但线程可能不会立即停止。
线程池实现“线程复用”的原理
线程池通过将线程和任务解耦,让同一个线程可以从BlockingQueue中不断提取新任务来执行。核心原理是让每个线程去执行一个“循环任务”,在这个“循环任务”中,线程会不断检查是否有任务等待执行,如果有则直接执行任务的run方法,从而实现线程复用。
你知道哪几种锁?分别有什么特点?
根据分类标准,锁可以分为以下7大类别:
- 偏向锁/轻量级锁/重量级锁:
- 偏向锁:适用于没有竞争的场景,线程可以直接获取锁,开销最小。
- 轻量级锁:适用于有少量竞争的场景,线程会通过自旋尝试获取锁。
- 重量级锁:适用于竞争激烈的场景,线程会进入阻塞状态。
- 可重入锁/非可重入锁:
- 可重入锁:线程可以多次获取同一把锁,例如
ReentrantLock。 - 非可重入锁:线程不能多次获取同一把锁。
- 可重入锁:线程可以多次获取同一把锁,例如
- 共享锁/独占锁:
- 共享锁:多个线程可以同时持有锁,例如读锁。
- 独占锁:同一时间只能有一个线程持有锁,例如写锁。
- 公平锁/非公平锁:
- 公平锁:线程按照排队顺序获取锁。
- 非公平锁:线程可能会插队获取锁。
- 悲观锁/乐观锁:
- 悲观锁:在获取资源前先加锁,例如
synchronized。 - 乐观锁:在不加锁的情况下完成操作,例如
AtomicInteger。
- 悲观锁:在获取资源前先加锁,例如
- 自旋锁/非自旋锁:
- 自旋锁:线程在获取锁失败后会不断尝试获取锁。
- 非自旋锁:线程在获取锁失败后会进入阻塞状态。
- 可中断锁/不可中断锁:
- 可中断锁:线程在等待锁时可以被中断,例如
ReentrantLock的lockInterruptibly方法。 - 不可中断锁:线程在等待锁时不能被中断,例如
synchronized。
- 可中断锁:线程在等待锁时可以被中断,例如
悲观锁与乐观锁
- 悲观锁:在获取资源前先加锁,例如
synchronized关键字和ReentrantLock。 - 乐观锁:在不加锁的情况下完成操作,例如
AtomicInteger。 - 数据库中的锁:
- 悲观锁:例如
SELECT ... FOR UPDATE,在提交之前不允许第三方修改数据。 - 乐观锁:通过版本号
version字段实现,在更新数据时检查版本号是否一致。
- 悲观锁:例如
如何看到synchronized背后的“monitor锁”?
- 获取和释放monitor锁的时机:线程在进入被
synchronized保护的代码块之前会自动获取锁,并且在退出时自动释放锁。 synchronized修饰的代码块:利用monitorenter和monitorexit指令实现。synchronized修饰的方法:利用ACC_SYNCHRONIZED标志实现。
synchronized和Lock孰优孰劣,如何选择?
- 如果能不用最好既不使用
Lock也不使用synchronized,优先使用java.util.concurrent包中的工具类。 - 如果
synchronized关键字适合你的程序,尽量使用它,因为使用Lock时如果忘记在finally里unlock,可能会导致问题。 - 如果需要
Lock的特殊功能(如尝试获取锁、可中断、超时功能等),则使用Lock。
参考资料: