①线程
❶基础
🌟线程和进程的区别
区别 | 线程 | 进程 |
---|---|---|
调度 | 程序执行的基本单位 | 资源管理的基本单位 |
资源 | 独立的内存和资源 | 共享本进程的地址空间和资源 |
关系 | 线程属于进程 | 进程包含线程 |
切换 | 上下文切换快 | 上下文切换慢 |
开销 | 创建、销毁开销小 | 创建、销毁开销大 |
🌟线程间通信方式
同一进程的线程共享地址空间,通信通过共享内存,一般来说只需要做好同步/互斥,保护共享的全局变量。
锁机制:包括互斥锁,读写锁,自旋锁,条件变量。synchronized 关键词和各种 Lock 都是这种机制。
- 互斥锁提供排他方式防止数据结构被修改的方法,
- 读写锁允许多个线程同时读共享变量,对写操作互斥,
- 自旋锁循环检测是否释放锁,
- 条件变量以原子方式阻塞进程,直到条件为真,与互斥锁一起使用。
共享内存机制:多个进程或者线程可以直接访问同一块物理内存地址,来传递数据。volatile
信号机制:通过明确的发送信息来显示的进行通信。Wait/Notify、join
信号量机制:允许同一时刻多个线程访问同一资源。Semaphore
消息队列机制:不同进程或者线程之间可以通过消息队列来传递数据。
管道机制:可以实现进程或者线程之间的单向通信。
🌟说说协程和线程区别
协程是一种比线程更加轻量级的存在。协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态中执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。因此协程的开销远远小于线程的开销。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其它地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
- 一个线程可以有多个协程,一个进程也可以单独拥有多个协程;
- 线程进程都是同步机制,而协程则是异步机制;
- 协程能保留上一次调用的状态,每次重入时,就相当于进入上一次调用的状态;
🌟同步和异步的区别
- 同步 :发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步 :调用在发出之后,不用等待返回结果,该调用直接返回。
🌟守护线程和用户线程的区别
用户线程:平时使用到的线程均为用户线程。
守护线程:用来服务用户线程的线程,例如垃圾回收线程。
守护线程和用户线程的区别主要在于Java虚拟机是否存活。
- 用户线程:当任何一个用户线程未结束,Java虚拟机则不会结束。
- 守护线程:如果只剩守护线程未结束,Java虚拟机结束。
任何线程都可以设置为守护线程和用户线程,通过方法Thread.setDaemon(true/false)
设置
- true则是将该线程设置为守护线程,false则是将该线程设置为用户线程。
- 必须在
Thread.start()
之前调用,否则运行时会抛出异常。
🌟为什么使用多线程
为了能提高程序的执行效率和运行速度
多个线程可以同时运行,这减少了线程上下文切换的开销
多线程机制可以大大提高系统整体的并发能力以及性能
多线程可能会遇到:内存泄漏、死锁、线程不安全等等。
🌟线程的上下文切换
CPU会通过时间片分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但切换前会保存上一个任务的状态,因为下次切换回这个任务时还要加载这个任务的状态继续执行,从任务保存到再加载的过程就是一次上下文切换。
🌟线程状态及转换
线程的生命周期和状态
在 Java API 中 java.lang.Thread.State
这个枚举中给出了六种线程状态:
线程状态 | 导致状态发生条件 |
---|---|
NEW | 初始状态,线程刚被创建,但并未启动,还没调用 start 方法 |
RUNNABLE | 运行状态,包含就绪和运行中两种状态,调用了 start 方法 |
BLOCKED | 阻塞状态,需要等待锁释放 |
WAITING | 等待状态 |
TIME_WAITING | 超时等待状态,和等待状态不同的是,它可以在制定的时间自行返回 |
TERMINATED | 终止状态,线程运行结束 |
🌟线程死锁是如何产生的,如何避免
死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时被阻塞。
死锁产生的条件:
- 互斥条件:一个资源在同一时刻只由一个线程占用。
- 请求与保持条件:一个线程在请求被占资源时发生阻塞,并对已获得的资源保持不放。
- 循环等待条件:发生死锁时,所有的线程会形成一个死循环,一直阻塞。
- 不剥夺条件:线程已获得的资源在未使用完不能被其他线程剥夺,只能由自己使用完释放资源。
避免死锁的方法主要是破坏死锁产生的条件。
- 破坏互斥条件:这个条件无法进行破坏,锁的作用就是使他们互斥。
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏循环等待条件:按顺序来申请资源。
- 破坏不剥夺条件:线程在申请不到所需资源时,主动放弃所持有的资源。
🌟死锁、活锁、饥饿有什么区别?
活锁:任务或者执行者没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败。
死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时被阻塞。
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源, 导致一直无法执行的状态。
活锁和死锁的区别:
- 活锁是在不断地尝试、死锁是在一直等待。
- 活锁有可能自行解开、死锁无法自行解开。
死锁和饥饿的区别:饥饿可自行解开,死锁不行。
❷使用
🌟创建线程的几种方式
Java实现多线程的方式
- 继承 Thread 类创建线程;
- 实现 Runnable 接口创建线程;
- 通过 Callable 和 Future 创建线程;
- 通过线程池创建线程。
🌟runnable 和 callable 有什么区别
- Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行 run() 方法中的代码而已;
- Callable 接口中的 call() 方法是有返回值的,是一个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果。
🌟run() 和 start() 有什么区别
- 线程是通过
Thread
对象所对应的方法run()
来完成其操作的,而线程的启动是通过start()
方法执行的。 run()
方法可以重复调用,start()
方法只能调用一次
🌟可以直接调用 Thread 类的 run 方法吗
为什么调用start()方法时会执行run()方法,而不直接执行run()方法?
结论:不能,调用 start()
方法可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行,而在主线程中执行的。
调用 start()
方法,会启动一个线程,这时线程处于就绪状态,当分配到CPU时间片后,就开始执行run()
方法,它包含了要执行的这个线程的内容,这是真正的多线程工作。
直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,这样就没有达到多线程的目的。
🌟sleep() 和 wait() 的区别
Thread.sleep()和Object.wait()的区别
共同点 :两者都可以暂停线程的执行。
区别 :
sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。sleep()
不会释放占有的锁,而wait()
会释放占有的锁 。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法,或者使用wait(long timeout)
超时后线程会自动苏醒。sleep()
方法执行完成后,线程会自动苏醒。
🌟为什么wait(),notify()必须在同步方法或者同步块中被调用
为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中
因为wait()暂停的是持有锁的对象,notify()或notifyAll()唤醒的是等待锁的对象。
所以wait()、notify()、notifyAll()都需要线程持有锁的对象,进而需要在同步方法或者同步块中被调用。可以被任意对象调用的方法是定义在Object类中。
🌟如何实现两个线程之间的通信和协作
syncrhoized
加锁的线程的Object
类的wait()
/notify()
/notifyAll()
- 使用
volatile
关键字。其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。 ReentrantLock
类加锁的线程的Condition
类的await()
/signal()
/signalAll()
- 基于
LockSupport
实现线程间的阻塞和唤醒。LockSupport
是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。 - 使用JUC工具类
CountDownLatch
。jdk1.5 之后在java.util.concurrent
包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch
基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
🌟线程同步?线程互斥?如何实现的?
线程互斥是指某一个资源只能被一个访问者访问,具有唯一性和排他性。但访问者对资源访问的顺序是乱序的。
线程同步是指在互斥的基础上使得访问者对资源进行有序访问。 需要等待前面结果返回,才能继续运行
线程同步的实现方法:
- 同步方法、同步代码块
wait()
和notify()
- 使用volatile实现线程同步
- 使用重入锁实现线程同步
- 使用局部变量实现线程同步
- 使用阻塞队列实现线程同步
🌟如何保证线程的运行安全
线程安全问题主要体现在原子性、可见性和有序性。
- 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。线程切换带来的原子性问题。
- 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。
- 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。
解决方法:
- 原子性问题:可用
Atomic原子类
、synchronized
、LOCK
来解决 - 可见性问题:可用
synchronized
、volatile
、LOCK
来解决 - 有序性问题:可用
Happens-Before
规则来解决
🌟线程安全的实现方法
- 互斥同步:synchronized , ReentrantLock
- 非阻塞同步:CAS,Atomic类
- 无同步方案:栈封闭,本地存储(ThreadLocal),可重入代码
🌟如何停止一个正在运行的线程
- 中断:
Interrupt
方法中断线程 - 使用
volatile boolean
标志位停止线程:在线程中设置一个boolean
标志位,同时用volatile
修饰保证可见性,在线程里不断地读取这个值,其他地方可以修改这个boolean
值。 - 使用
stop()
方法停止线程,但该方法已经被废弃。因为这样线程不能在停止前保存数据,会出现数据完整性问题。
🌟说说Java 内存模型(JMM)的理解?
CPU 处理速度和内存处理速度不对等,所以需要在中间建立中间层,也就是高速缓存,这会引出缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),有可能操作同一位置引起各自缓存不一致,这时候需要约定协议在保证一致性。
Java 内存模型(Java Memory Model,JMM):屏蔽掉了各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致性的内存访问效果
java内存模型定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。 工作内存中保存主内存副本和自己私有变量,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存。
②关键字
❶synchronized
synchronized解决的是多个线程之间访问资源的同步性,synchronized可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
🌟synchronized的用法有哪些?
如何在项目中使用 synchronized 的?
synchronized关键字的使用方法?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁
- 修饰代码块:指定加锁对象,synchronized(对象的引用)锁的是对象实例,synchronized(类.class)锁的是类
🌟synchronized三大特性是什么?
synchronized的作用有哪些
可见性和原子性有什么区别
原子性:一个或多个操作要么全部执行成功,要么全部执行失败。确保线程互斥的访问同步代码;
可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。保证共享变量的修改能够及时可见;
有序性:程序的执行顺序会按照代码的先后顺序执行。有效解决重排序问题。
🌟synchronized可实现什么类型的锁?
悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
独占锁/排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。
🌟synchronized 底层实现原理?
尝试获取对象的Monitor
,Monitor
已被其他线程占用时,获取失败,该线程进入EntrySet
(阻塞队列)。占有Monitor
时调用wait()
进入WaitSet
(等待队列)。调用notify()
时从WaitSet
里随机选一个线程唤醒,调用notifyAll
时唤醒WaitSet
里所有线程
synchronized 同步代码块的实现是通过 monitorenter
和 monitorexit
指令,
monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
当执行 monitorenter
指令时,线程试图获取锁也就是获取 monitor
的持有权(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit
指令后,将锁计数器设为0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
synchronized用的锁是存在对象头里的,对象头则由Mark Word和Class MetadataAddress组成。Mark Word存储对象的hashCode、分代年龄、锁标记位、偏向线程ID等信息。Java虚拟机是基于Monitor对象来实现重量级锁的,monitor对象存在于每个Java对象的对象头中
Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter 和 monitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。
🌟JDK1.6后synchronized做了哪些优化
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
36.jdk1.6为什么要对synchronized进行优化?做了哪些优化? - 路人张的面试笔记 (mianshi.online)
说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?-帅地玩编程 (iamshuaidi.com)
🌟锁升级过程
synchronized锁有四种状态,无锁,偏向锁,轻量级锁,重量级锁,这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态
🌟偏向锁、轻量级锁、重量级锁的对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块执行速度较长 |
🌟了解锁消除吗?了解锁粗化吗?
锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。
锁粗化是指当虚拟机探测到一串的操作对相同对象多次加锁,导致线程发生多次重入,将会把加锁的范围扩展(粗化)到整个操作序列的外部。因为频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化
🌟synchronized和volatile的区别
volatile
主要是告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
作用于变量,synchronized
作用于代码块或者方法。volatile
仅可以保证数据的可见性和有序性,不能保证数据的原子性。synchronized
可以保证数据的可见性、有序性、原子性。volatile
不会造成线程的阻塞,synchronized
会造成线程的阻塞。volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化。
🌟synchronized和ReentrantLock区别
相同点:两者都是可重入锁,即自己可以再次获取自己的内部锁
区别:
1、synchronized 是关键字,ReentrantLock 是类,这是二者的本质区别。
既然 ReentrantLock 是类,那么它就提供了比 synchronized 更多更灵活的特性:等待可中断、可实现公平锁、可实现选择性通知(锁可以绑定多个条件)、性能已不是选择标准。
等待可中断 : ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
可实现公平锁 : ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过 ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。
可实现选择性通知(锁可以绑定多个条件): synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
2、 synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API。
synchronized 是依赖于 JVM 实现的,JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
3、ReentrantLock 需要手动解锁,synchronized 执行完代码块自动解锁
🌟synchronized和Lock的区别
synchronized | Lock | |
---|---|---|
本质 | 关键字,是依赖JVM实现的 | 接口,是JDK实现的 |
类型 | 隐士锁,可以自动释放锁 | 显示锁,需要手动开启和关闭 |
中断 | 不可中断锁,需要线程执行完才能释放锁 | 可中断锁 |
锁类型 | 可重入锁,非公平锁 | 可重入锁、公平锁 |
发生异常时 | 会自动释放占有的锁,不会出现死锁的情况 | 不会主动释放占有的锁,必须通过unlock手动释放,因此可能引发死锁 |
场景 | 适用于少量同步代码块的场景 | 适用于大量同步代码块的场景 |
❷volatile
🌟volatile的特性有哪些?
并发编程的三大特性为可见性、有序性和原子性。通常来讲
volatile
可以保证可见性和有序性。
- 可见性:
volatile
可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。如果我们将变量声明为volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。 - 有序性:
volatile
会通过禁止指令重排序进而保证有序性。 - 原子性:对于单个的
volatile
修饰的变量的读写是可以保证原子性的,但对于i++
这种复合操作并不能保证原子性。这句话的意思基本上就是说volatile
不具备原子性了。
🌟volatile的作用是什么?
- 保证变量对所有线程的可见性。 当一条线程修改了变量值,新值对于其他线程来说是立即可以得知的。
- 禁止指令重排序优化。使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,编译器不会将后面的指令重排到内存屏障之前。
🌟如何保证变量的可见性?
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
🌟volatile实现内存可见性原理?
导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?
volatile
可以保证内存可见性的关键是volatile
的读/写实现了缓存一致性,缓存一致性的主要内容为:
- 每个处理器会通过嗅探总线上的数据来查看自己的数据是否过期,一旦处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存设为无效状态。此时,如果处理器需要获取这个数据需重新从主内存将其读取到本地内存。
- 当处理器写数据时,如果发现操作的是共享变量,会通知其他处理器将该变量的缓存设为无效状态。
那缓存一致性是如何实现的呢?
通过volatile
修饰的变量,生成汇编指令时会比普通的变量多出一个Lock
指令,这个Lock
指令就是volatile
关键字可以保证内存可见性的关键,它主要有两个作用:
- 将当前处理器缓存的数据刷新到主内存。
- 刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效。
🌟volatile如何实现有序性
为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
happens-before等
🌟as-if-serial & happens-before
as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。
as-if-serial 编译器等会对原始的程序进行指令重排序和优化。但不管怎么重排序,其结果和用户原始程序输出预定结果一致。
happens-before是可见性与有序性的一套规则总结,JVM会对代码进行编译优化,会出现指令重排序情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。八大规则
程序次序规则:一个线程内写在前面的操作先行发生于后面的。
锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
volatile 规则:线程对 volatile 变量的写,对接下来其它线程对该变量的读可见。
线程启动规则:线程的 start 方法先行发生于线程的每个动作。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
线程终止规则:线程中所有操作先行发生于对线程的终止检测。
对象终结规则:对象的初始化先行发生于 finalize 方法。
传递性规则:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C
🌟volatile能保证原子性吗?
volatile
关键字能保证变量的可见性、有序性,但不能保证对变量的操作是原子性的。
一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。
🌟i++为什么不能保证原子性?
i++
其实是一个复合操作,包括三步:
- 读取 i 的值。
- 对 i 加 1。
- 将 i 的值写回内存。
🌟如何保证多线程下 i++ 结果正确
- 使用循环CAS+volatile,实现 i++原子操作;
- 使用 synchronized,实现 i++原子操作;
- 使用 Lock锁机制,实现i++原子操作;
- 使用 AtomicInteger:由硬件提供原子操作指令实现的;
- Semaphore构造方法中传入的参数是1的时候,此时线程并发数最多是1个,即是线程安全的,这种方式也可以做到现场互斥。
🌟32位机器上共享的long和double变量的为什么要用volatile? 64位机器上是否也要设置呢?
对于32位的虚拟机来说,每次原子读写都是32位的,会将long
和double
型变量拆分成高32位和低32位的两个操作来执行,这样long
和double
型变量的读写就不能保证原子性了,因此32位机器上共享的long
和double
变量的必须加上volatile
保证其原子性
64位的long
型和double
型变量的可以保证原子性,因此不用设置。目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。
🌟说下volatile的应用场景?
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
- 例子 1: 单例模式
- 例子2: volatile bean
🌟双重检验锁(DCL)实现单例模式的原理
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (singleton == null) {
//类对象加锁
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
为什么要在变量 singleton
之间加上 volatile
关键字?
因为对象的构造过程分为三个步骤:(singleton = new Singleton();
)
- 分配内存空间【为
singleton
分配内存空间】 - 初始化对象【初始化
singleton
】 - 将内存空间的地址赋给对象的引用【将
singleton
指向分配的内存地址】
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时 T2 调用
getUInstance
() 后发现singleton
不为空,因此返回singleton
,但此时singleton
还未被初始化。
🌟为什么要进行指令重排?
计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。
指令重排序一般分为编译器优化重排、指令并行重排和内存系统重排三种。
- 编译器优化重排:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序。
- 指令并行重排:现代处理器多采用指令级并行技术来将多条指令重叠执行。
- 内存系统重排:处理器使用缓存和读/写缓冲区,使得加载和存储看上去像是在乱序执行。
这三种指令重排说明了一个问题,指令重排在单线程下可以提高代码的性能,但在多线程下会破坏程序的语义
❸final
🌟 final基础使用
final
修饰的类不能被继承final
修饰的方法不能被重写final
修饰的变量是基本数据类型则值不能改变final
修饰的变量是引用类型则不能再指向其他对象
final方法可以被重载吗? 可以
父类的final方法能不能够被子类重写? 不可以
如果字段由static和final修饰,仅能在声明时赋值或声明后在静态代码块中赋值,因为该字段属于这个类。
🌟所有final修饰的字段都是编译期常量吗
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
final int k = r.nextInt();
public static void main(String[] args) {
}
}
不是所有的final修饰的字段都是编译期常量,k的值由随机数对象决定,只是k的值在被初始化后无法被更改。
🌟说说final类型的类如何拓展?
比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合, 如下代码大概写个组合实现的意思:
class MyString{
private String innerString;
// ...init & other methods
// 支持老的方法
public int length(){
return innerString.length(); // 通过innerString调用老的方法
}
// 添加新方法
public String toMyString(){
//...
}
}
🌟说说final的原理?
🌟String为什么不可变
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[];
//...
}
String
内部用private final
修饰char
数组,并且String
类没有提供暴露修改这个数组的方法。- 用
final
修饰char
数组,这个数组无法被修改。(仅仅是指引用地址不可被修改,并非是value[]这个数组的内容不可修改) - 用
private
修饰char
数组,且String
类不提供修改这个数组的方法,所以初始化之后我们没法改变数组的内容。
- 用
String
类被final
修饰导致其不能被继承,进而避免了子类破坏String
不可变。
Java 作者在 String 的所有方法里面,都很小心地避免去修改了 char 数组中的数据,涉及到对 char 数组中数据进行修改的操作全部都会重新创建一个 String 对象。比如 substring 方法
如何让String可变
value是私有属性,你只需要一种方法访问类的私有属性即可。使用反射可以直接修改value数组中的内容,当然建议不要这样做。
③无锁
❶CAS
🌟什么是CAS?
CAS全称Compare And Swap
,比较与交换,Java中可以通过CAS操作来保证原子性,是乐观锁的主要实现方式。CAS在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock
内部的AQS和原子类内部都使用了CAS。
CAS算法涉及到三个操作数:
- 需要读写的内存值V(内存值)。
- 进行比较的值A(旧值)。
- 要写入的新值B(新值)。
只有当V的值等于A时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。
当多个线程同时操作一个共享变量时,只有一个线程可以对变量进行成功更新,其他线程均会失败,但是失败并不会被挂起,进行再次尝试,也就是自旋。Java中的自旋锁就是利用CAS来实现的。
自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。
🌟CAS存在的问题及优点
其中ABA问题是面试中比较常见的问题
- ABA问题
在CAS的算法流程中,首先要先比较V的值和A的值,如果相等则进行更新。
ABA 问题:当进行获取主内存值时,该内存值在写入主内存时已经被修改了 N 次,但是最终又改成原来的值。即其他线程先把 A 改成 B 又改回 A,主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况,这时 CAS 虽然成功,但是过程存在问题。
ABA 问题的解决方式:ABA 的解决方法也很简单,就是利用版本号。给变量加上一个版本号,每次变量更新的时候就把版本号加1,这样即使 旧值A 的从 A—>B—>A,版本号也发生了变化,这样就解决了CAS出现的ABA问题。基于CAS的乐观锁也是这个实现原理。
- 循环时间过长导致开销太大
CAS自旋时间过长会给CPU带来非常大的开销,在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。
- 只能保证一个共享变量的原子操作
在操作一个共享变量时,可以通过CAS的方式保证操作的原子性,但如果对多个共享变量进行操作时,CAS则无法保证操作的原子性,这时候就需要用锁了。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
那么CAS有什么优点呢?在并发量不是很大时提高效率。
🌟说下对悲观锁和乐观锁的理解?
- 悲观锁
总是假设最坏的情况,每次去操作数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
传统的关系型数据库里边就用到了很多这种锁机制,比如:行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
- 乐观锁
总是假设最好的情况,每次去操作数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
- 两种锁的使用场景
两种锁各有优缺点,不可认为一种好于另一种,
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
🌟乐观锁和悲观锁的区别可以说一下吗?
悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized
和ReentrantLock
属于悲观锁。
乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS
。
🌟乐观锁常见的两种实现方式是什么?
乐观锁一般会使用版本号机制或者 CAS 算法实现。
版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
CAS 算法
即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数:需要读写的内存值 V、进行比较的值 A、拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
🌟CAS 和 synchronized 的使用场景?
悲观锁和乐观锁的使用场景
CAS 适用于写比较少的情况下(多读场景,冲突一般较少)
对于资源竞争较少(线程冲突较轻)的情况, CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
原子操作类是CAS在Java中的应用
并发队列的无锁化也是通过CAS实现
synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
🌟JVM 中的 CAS 是怎么实现的?
JAVA中的CAS操作都是通过Unsafe类实现,映射到操作系统就是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值。其实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg指令,就是说CAS是靠硬件实现的,从而在硬件层面提升效率。
❷Atomic
🌟请阐述你对Unsafe类的理解?
Unsafe 是 CAS 的核心类,由于 Java 无法直接访问底层系统,需要通过本地(Native)方法来访问
Unsafe类可以直接操作特定的内存数据,其内部方法操作类似 C 的指针,这无疑也增加了程序发生相关指针问题的风险。
Unsafe 类主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,其中所有方法都是 native 修饰的,都是直接调用操作系统底层资源执行相应的任务,
在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。
Java魔法类:Unsafe应用解析 - 美团技术团队 (meituan.com)
🌟简单说下对 Java 中的原子类的理解?
说说作用和使用场景。
Atomic 指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
原子操作类是基于CAS(基于Unsafe
实现)实现的,提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。包含4组,13个
JUC包中的4种原子类
- 基本类型:使用原子的方式更新基本类型
AtomicInteger
:整形原子类AtomicLong
:长整型原子类AtomicBoolean
:布尔型原子类
- 数组类型:使用原子的方式更新数组里的某个元素
AtomicIntegerArray
:整形数组原子类AtomicLongArray
:长整形数组原子类AtomicReferenceArray
:引用类型数组原子类
- 引用类型:
AtomicReference
:引用类型原子类,存在ABA问题AtomicStampedReference
:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。AtomicMarkableReference
:原子更新带有标记位的引用类型
- 原子更新字段类
AtomicIntegerFieldUpdater
:原子更新整型的字段的更新器。AtomicLongFieldUpdater
:原子更新长整型字段的更新器。AtomicReferenceFieldUpdater
:原子更新引用类型字段的更新器
🌟Atomic 的原理是什么?
Atomic 通过 CAS+volatile实现,当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
❸ThreadLocal
🌟什么是ThreadLocal?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
ThreadLocal
类正是为了解决这样的问题。
ThreadLocal
为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,就不会和其他线程的局部变量冲突,实现了线程间的数据隔离。
如果你创建了一个ThreadLocal
变量,可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
🌟ThreadLocal原理?如何实现线程隔离?
JDK8 以后:每个 Thread 内部维护一个 ThreadLocalMap,Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值,这个 Map 的 key 是 ThreadLocal 实例本身,value 是真正要存储的值,对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成副本的隔离,互不干扰
每个线程都有一个ThreadLocalMap
(ThreadLocal
的静态内部类),Map中元素的键为ThreadLocal
,而值对应线程的变量副本。
调用threadLocal.set()
–>调用getMap(Thread)
–>返回当前线程的ThreadLocalMap<ThreadLocal, value>
–>map.set(this, value)
,this是threadLocal
本身。源码如下:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
调用get()
–>调用getMap(Thread)
–>返回当前线程的ThreadLocalMap<ThreadLocal, value>
–>map.getEntry(this)
,返回value
。源码如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
🌟ThreadLocal有哪些应用场景?
ThreadLocal
的应用场景主要有以下几个方面:
每个线程需要有自己单独的实例
实例需要在多个方法中共享,但不希望被多线程共享
保存线程上下文信息,在需要的地方可以获取
线程间数据隔离
数据库连接
session管理
用于保存线程不安全的工具类,SimpleDateFormat
🌟ThreadLocal内存泄露? 如何解决
ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocal 内存泄漏如何解决
ThreadLocal 内部如何防止内存泄漏,在哪些方法中存在
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
如何解决
ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会主动清理掉 key 为 null 的无效 Entry 来避免内存泄漏问题。
要彻底解决内存泄漏问题:每次使⽤完ThreadLocal
就调⽤它的remove()
⽅法,最好手动将对应的键值对删除,从⽽避免内存泄漏。
🌟ThreadLocal 底层数据结构
ThreadLocal 底层是通过 ThreadLocalMap 这个静态内部类来存储数据的,ThreadLocalMap 就是一个键值对的 Map,它的底层是 Entry 对象数组,Entry 对象中存放的键是 ThreadLocal 对象,值是 Object 类型的具体存储内容。
🌟ThreadLocalMap 的散列方式
// 获取当前 ThreadLocal 对象的散列值
int i = key.threadLocalHashCode & (table.length - 1);
这行代码得到的值其实是一个 ThreadLocal 对象的散列值,这就是 ThreadLocal 的散列方式,我们称之为 斐波那契散列 。
ThreadLocal 的散列方式称之为斐波那契散列,每次获取哈希值都会加上 HASH_INCREMENT
,这样做可以尽量避免 hash 冲突,让哈希值能均匀的分布在 2 的 n 次方的数组中
计算 ThreadLocal 对象的哈希值:
private final int threadLocalHashCode = nextHashCode() private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
每创建一个 ThreadLocal 对象就会使用 nextHashCode 分配一个 hash 值给这个对象:
private static AtomicInteger nextHashCode = new AtomicInteger()
斐波那契数也叫黄金分割数,hash 的增量就是这个数字,带来的好处是 hash 分布非常均匀:
private static final int HASH_INCREMENT = 0x61c88647
🌟ThreadLocalMap 如何处理哈希冲突
ThreadLocalMap 使用线性探测法来解决哈希冲突,该方法会一直探测下一个地址,直到有空的地址后插入,若插入后 Map 数量超过阈值,数组会扩容为原来的 2 倍,在探测过程中 ThreadLocal 会复用 key 为 null 的脏 Entry 对象,并进行垃圾清理,防止出现内存泄漏
假设当前 table 长度为16,计算出来 key 的 hash 值为 14,如果 table[14] 上已经有值,并且其 key 与当前 key 不一致,那么就发生了 hash 冲突,这个时候将 14 加 1 得到 15,取 table[15] 进行判断,如果还是冲突会回到 0,取 table[0],以此类推,直到可以插入,可以把 Entry[] table 看成一个环形数组
🌟ThreadLocalMap 扩容机制
ThreadLocalMap 的初始容量是 16,在扩容前有两个判断的步骤,都满足后才会进行最终扩容
- ThreadLocalMap#set(ThreadLocal<?> key, Object value) 方法中可能会触发启发式清理,在清理无效 Entry 对象后,如果数组长度大于等于数组定义长度的 2/3,则首先进行 rehash;
- rehash 会触发一次全量清理,如果数组长度大于等于数组定义长度的 1/2,则进行 resize(扩容);
进行扩容时,Entry 数组扩容为 原来的2倍 ,重新计算 key 的散列值,如果遇到 key 为 NULL 的情况,会将其 value 也置为 NULL,帮助虚拟机进行GC。
🌟ThreadLocal原理总结
ThreadLocal 将自身作为key,和需要保存的value一起存入到当前线程的 threadlocalmap 中。
ThreadLocalMap 的 key 是一个 threadLocal 变量,ThreadLocalMap 对它的引用是一个弱引用,也就是说,如果除了 ThreadLocalMap 以外没有指向 threadLocal 的引用,那么这个 threadlocal 就会被回收。
ThreadLocalMap 使用线性探测法解决哈希冲突:每个 threadlocal 在插入时会检查数组的当前位置的情况,如果为 null 直接插入,不为 null 则顺序查找下一个,直到找到空位置为止。
当 ThreadLocalMap 内元素超过最大大小的 3/4 时会进行扩容,新 map 大小为 2 倍。扩容时从第一个entry 开始,依次往新 map 里迁移,当中会丢弃掉 key 为 null 的。
④线程池
🌟什么是线程池?
线程池:一个容纳多个线程的容器,容器中的线程可以重复使用,避免频繁创建和销毁线程对象的操作
线程池的核心思想:线程复用,同一个线程可以被重复使用,来处理多个任务
池化技术 (Pool) :一种编程技巧,核心思想是资源复用,在请求量大时能优化应用性能,降低系统频繁建连的资源开销
🌟为什么要用线程池?
为什么要使用Executor线程池框架呢?说下对线程池的理解?
线程池能够对线程进行统一分配,调优和监控:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。使用线程池可以对线程进行统一的分配、调优和监控。
缺点
- 死锁
- 线程泄露
🌟线程池创建的方法
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
线程池的常用创建方式主要有两种,通过通过ThreadPoolExecutor 的构造方法创建和Executors工厂方法创建
方式一:通过 ThreadPoolExecutor 的构造方法实现:
方式二:通过 Executor 框架的工具类 Executors 来实现,按照其设计好的三个线程池模板进行创建
- FixedThreadPool :返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
- SingleThreadExecutor: 返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
- CachedThreadPool:返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
🌟不允许使用Executors创建线程池?
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式
1、Executors提供的模板不够灵活,多数情况需要程序员自定义线程池
2、模板容易造成内存溢出(1和2队列过载、3线程池过载)
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
CachedThreadPool :允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
🌟ThreadPoolExecutor构造函数参数分析
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
corePoolSize(线程池的基本大小)
:核心线程数,定义了最小可以同时运行的线程数量。maximumPoolSize(线程池最大数量)
:线程中允许存在的最大工作线程数量keepAliveTime(线程活动保持时间)
:当线程池中的数量大于核心线程数时,如果没有新的任务提交,核心线程之外的线程不会立即销毁,而是会等到时间超过keepAliveTime
时才会被销毁。unit(线程活动保持时间的单位)
:keepAliveTime
参数的时间单位。可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS,千分之一微秒)。**
workQueue(任务队列)
**:存放任务的阻塞队列。新来的任务会先判断当前运行的线程数是否到达核心线程数,如果到达的话,任务就会先放到阻塞队列。threadFactory(线程工厂)
:为线程池提供创建新线程的线程工厂。handler(饱和策略)
:当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时的拒绝策略。
🌟ThreadPoolExecutor的任务队列
可以选择以下几个阻塞队列:(都是BlockingQueue 的实现类)
1)ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO 原则对元素进行排序。
2)LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool() 使用了这个队列。
3)SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列。
4)PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
🌟ThreadPoolExecutor的饱和策略
说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略? 默认是什么策略?
当队列满了并且worker的数量达到maxSize的时候,会怎么样? 执行具体的拒绝策略
当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时,ThreadPoolExecutor
会指定一些饱和策略。主要有以下四种类型:
AbortPolicy
策略:该策略会直接抛出异常拒绝新任务,默认策略CallerRunsPolicy
策略:只用调用者所在线程来运行任务。DiscardPolicy
策略:直接丢弃新任务。DiscardOldestPolicy
策略:丢弃最早的未处理的任务请求。
当然,也可以根据应用场景需要来实现 RejectedExecutionHandler 接口自定义策略。如记录日志或持久化存储不能处理的任务。
补充:其他框架拒绝策略
- Dubbo:在抛出 RejectedExecutionException 异常前记录日志,并 dump 线程栈信息,方便定位问题
- Netty:创建一个新线程来执行任务
- ActiveMQ:带超时等待(60s)尝试放入队列
- PinPoint:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略
🌟线程池的运行流程
假如我把 corepoolsize 和 maxpoolsize 分别设置为 5 和 10,假设任务计算时间很长,我往里面连续放入 20 个任务,按时间顺序会发生什么
创建线程池创建后提交任务的流程如下图所示:
创建线程池时,没有线程,等待提交过来的任务请求(懒惰),调用 execute 方法才会创建线程【①】
当调用 execute() 方法添加一个请求任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务【②】
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列【③】
- 如果这时队列满了且正在运行的线程数量还小于 maximumPoolSize,那么会创建非核心线程立刻运行这个任务【④】
- 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行【⑤】
当一个线程完成任务时,会从队列中取下一个任务来执行【take/poll】
当一个线程空闲超过一定的时间(keepAliveTime)时,线程池会判断:如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。线程池的所有任务完成后最终会收缩到 corePoolSize 大小
🌟线程池中任务是如何提交的?
方式一:提交无返回值的任务 execute()
方式二:提交有返回值的任务 submit()
execute():执行任务,但是没有返回值,没办法获取任务执行结果,出现异常会直接抛出任务执行时的异常。根据线程池中的线程数,选择添加任务时的处理方式
submit():提交任务,把 Runnable 或 Callable 任务封装成 FutureTask 执行,可以通过方法返回的任务对象,调用 get 阻塞获取任务执行的结果或者异常
🌟execute()方法和submit()方法的区别
这个地方首先要知道Runnable接口和Callable接口的区别,之前有写到过
execute()
和submit()
的区别主要有两点:
execute()
方法只能执行Runnable
类型的任务,没有返回值。submit()
方法可以执行Runnable
和Callable
类型的任务,当任务类型为Callable
时,返回值类型为Future
,但当任务类型为Runnable
时,返回值为null
。execute()
会直接抛出任务执行时的异常,submit()
会吞掉异常,可通过 Future 的get()
方法将任务执行时的异常重新抛出
execute() 方法 |
submit() 方法 |
---|---|
在 Executor 接口中声明 |
在 ExecutorService 接口中声明 |
执行Runnable 类型的任务 |
执行Runnable 和 Callable 类型的任务 |
返回类型为 void |
返回类型为 Future/void |
会直接抛出任务执行时的异常 | 会吞掉异常,可通过 Future 的 get() 方法将任务执行时的异常重新抛出 |
🌟线程池中任务是如何关闭的?
线程池自动关闭的两个条件:1、线程池的引用不可达;2、线程池中没有线程;
shutdown()
:线程池状态变为 SHUTDOWN,等待任务执行完后关闭线程池,不会接收新任务,但已提交任务会执行完,而且也可以添加线程(不绑定任务)
shutdownNow()
:线程池状态变为 STOP,用 interrupt 中断正在执行的任务,直接关闭线程池,不会接收新任务,会将队列中的任务返回
🌟线程池中的的线程数怎么设置?
配置线程池需要考虑哪些因素?
线程池里的具体的数量,如何去定
性质不同的任务可用使用不同规模的线程池分开处理:(核心线程数常用公式)
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务。比如在内存中对大量数据进行排序。
IO 密集型是涉及到网络读取,文件读取的这类任务。这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
⑤JUC锁
❶AQS
🌟AQS 的原理是什么?
AQS(AbstractQueuedSynchronizer) 抽象队列同步器。是一个用来构建锁和同步器的框架,像ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,SynchronousQueue,FutureTask都是基于AQS实现的。
主要依赖于一个双向链表(FIFO等待队列)和一个volatile类型的整数state来实现同步控制。如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。
子类通过继承同步器并实现它的抽象方法getState、setState 和 compareAndSetState对同步状态进行更改。
AQS 是维护了一个共享资源和一个 FIFO 的线程等待队列
private volatile int state; //用于展示当前临界资源的获锁情况
通过volatile
来保证 state 的线程可见性,state 的访问方式主要有三种,如下
protected final int getState() { //获取state的值
return state;
}
protected final void setState(int newState) { //设置state的值
state = newState;
}
//使用 volatile 修饰配合 cas,保证修改时的原子性
protected final boolean compareAndSetState(int expect, int update) { //通过CAS操作更新state的值
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
🌟AQS的资源共享方式有哪些?
🌟AQS 共享锁和独占锁的区别?
- 独占式:同一时刻只能有一个线程获取同步状态,例如ReentrantLock,又可分为公平锁和非公平锁
- 共享式:同一时刻可以有多个线程获取同步状态,如:CountDownLatch、Semaphore、 CyclicBarrier、ReadWriteLock。
acquire 和 acquireShared 的区别:acquireShared 在获取锁后会调用 doReleaseShared() 唤醒下一个节点;acquire 不会,只会唤醒一个
release 和 releaseShared 的区别:releaseShared 需要确保线程安全,因为可能会有几个线程同时释放;release不需要保证线程安全,因为一定是独占的
🌟AQS底层使用了什么样的设计模式?
模板, 共享锁和独占锁在一个接口类中。
🌟如何使用AQS自定义同步器?
AQS 底层使用了模板方法模式,你能说出几个需要重写的方法吗?
AQS的底层使用了模板方法模式,自定义同步器只需要两步:
第一,继承AbstractQueuedSynchronizer
第二,重写以下几种方法:
- isHeldExclusively() :该线程是否正在独占资源。只有用到 condition 才需要去实现它。
- tryAcquire(int) :独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
- tryRelease(int) :独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
- tryAcquireShared(int) :共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int) :共享方式。尝试释放资源,成功则返回 true,失败则返回 false。
❷ReentrantLock
🌟ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程ID和当前请求的线程ID是否一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。
🌟ReentrantLock怎么实现的? 继承
通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持。
ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。
- ReentrantLock是如何实现公平锁的? FairSync
- ReentrantLock是如何实现非公平锁的? UnFairSync
- ReentrantLock默认实现的是公平还是非公平锁?非公平锁
🌟ReentrantReadWriteLock特点
读锁和写锁分离:ReentrantReadWriteLock中包含了两种锁,读锁ReadLock和写锁WriteLock,可以通过这两种锁实现线程间的同步。
ReentrantReadWriteLock 其读锁是共享锁,写锁是独占锁,可实现读读共享,读写互斥,写写互斥
🌟ReentrantReadWriteLock底层实现原理
基于AQS和Lock实现
ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示。
Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。
🌟ReentrantReadWriteLock底层如何设计
高16位为读锁,低16位为写锁
读锁和写锁的最大数量是多少? 2^16 - 1
写锁的获取与释放是怎么实现的? tryAcquire/tryRelease
读锁的获取与释放是怎么实现的? tryAcquireShared/tryReleaseShared
❸Semaphore
🌟Semaphore 有什么用?
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;
每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
🌟Semaphore 的原理是什么?
Semaphore
即信号量, 是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
(许可证),只有拿到许可证的线程才能执行。使用 acquire
方法获得一个许可证,计数器减一,使用 release
方法释放许可,计数器加一。如果此时计数器值为0,线程进入休眠。
调用
semaphore.acquire()
,线程尝试获取许可证,如果state>=0
的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改state
的值state=state-1
。如果state<0
的话,则表示许可证数量不足。此时会创建一个Node
节点加入阻塞队列,挂起当前线程。调用
semaphore.release()
,线程尝试释放许可证,并使用 CAS 操作去修改state
的值state=state+1
。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改state
的值state=state-1
,如果state>=0
则获取令牌成功,否则重新进入阻塞队列,挂起线程。
🌟Semaphore场景问题
Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
- 拿不到令牌的线程阻塞,不会继续往下运行。
Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
- 线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。
Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
- 能,原因是release方法会添加令牌,并不会以初始化的大小为准。
Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
- 能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。
❹LockSupport
当调用LockSupport.park
时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark
时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。
🌟为什么LockSupport也是核心基础类?
AQS框架借助于两个类:Unsafe(提供CAS操作)和LockSupport(提供park/unpark操作)
🌟通过wait/notify实现同步
//挂起线程
synchronized(lock){
while(条件成立){
lock.wait();
}
// 工作代码
}
//唤醒线程
synchronized(lock){
lock.notifyAll();
}
使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。
🌟通过park/unpark实现同步
/**
*先 park 再 unpark
**/
//挂起线程
Thread t1 = new Thread(() -> {
sleep(1);
LockSupport.park();
// 工作代码
},"t1").start();
sleep(2);
//唤醒线程
LockSupport.unpark(t1);
/**
*先 unpark 再 park
**/
//挂起线程
Thread t1 = new Thread(() -> {
sleep(2);
LockSupport.park();
// 工作代码
},"t1").start();
sleep(1);
//唤醒线程
LockSupport.unpark(t1);
使用park/unpark实现同步时,可以先调用unpark,再调用park,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。
🌟Object.wait()和Condition.await()的区别
Object.wait()
和Condition.await()
的原理是基本一致的,不同的是Condition.await()
底层是调用LockSupport.park()
来实现阻塞当前线程的。
实际上,它在阻塞当前线程之前还干了两件事,一是把当前线程添加到条件队列中,二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()
阻塞当前线程。
🌟sleep()和park()的区别
LockSupport.park()还有几个兄弟方法:parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。
相同点:都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
区别:
Thread.sleep() | LockSupport.park() | |
---|---|---|
唤醒 | 没法从外部唤醒,只能自己醒过来 | 可以被另一个线程调用LockSupport.unpark()方法唤醒。 |
异常 | 方法声明上抛出了InterruptedException 中断异常,调用者需要捕获这个异常或者再抛出; |
不需要捕获中断异常 |
底层 | 本身就是native 方法 |
底层是调用的Unsafe 的native 方法 |
🌟wait()和park()的区别
相同点:二者都会阻塞当前线程的运行
区别:
Object.wait() | LockSupport.park() | |
---|---|---|
定义位置 | 在synchronized 块中执行 |
可以在任意地方执行 |
使用规则 | notify 随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程 | unpark 以线程为单位来阻塞和唤醒线程 |
使用顺序 | wait & notify 不能先 notify | park & unpark 可以先 unpark |
释放锁资源 | 会释放锁资源进入等待队列 | park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU |
中断异常 | 声明抛出了中断异常,调用者需要捕获或者再抛出 | 不需要捕获中断异常 |
park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。
🌟如果在wait()之前执行了notify()会怎样
如果当前的线程不是此对象锁的所有者,却调用该对象的notify()或wait()方法时抛出
IllegalMonitorStateException
异常;如果当前线程是此对象锁的所有者,wait()将一直阻塞,因为后续将没有其它notify()唤醒它。
结论:wait & notify 不能先 notify
🌟如果在park()之前执行了unpark()会怎样
线程不会被阻塞,直接跳过park(),继续执行后续内容
结论:先 park 再 unpark 和先 unpark 再 park 效果一样,都会直接恢复线程的运行
🌟LockSupport.park()会释放锁资源吗?
不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。
⑥JUC工具类
❶CountDownLatch
🌟什么是CountDownLatch?
CountDownLatch 有什么用?
CountDownLatch,计数器,底层也是由AQS,用来进行线程同步协作,等待所有线程完成。
CountDownLatch 允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。通过一个计数器来实现的,计数器的初始值 count
是线程的数量。每当一个线程执行完毕后,调用countDown方法,计数器的值就减1,当计数器的值为0时,表示所有线程都执行完毕,然后在等待的线程就可以恢复工作了。 只能一次性使用,不能reset。(CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用)
CountDownLatch一次可以唤醒几个任务? 多个
🌟CountDownLatch底层实现原理?
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
。当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。然后,CountDownLatch
会自旋 CAS 判断 state == 0
,如果 state == 0
的话,就会释放所有等待的线程,await()
方法之后的语句得到执行。
🌟CountDownLatch有哪些主要方法?
CountDownLatch(int count)
:构造方法,初始化唤醒需要 down 几步
await()
:此函数将会使当前线程在计数器倒计至零之前一直等待,除非线程被中断。
countDown()
:此函将计数器进行减 1,如果计数到达零,则释放所有等待的线程
🌟用过 CountDownLatch 么?什么场景下用的?
CountDownLatch
的作用就是 允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch
。具体场景是下面这样的:
我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。
为此我们定义了一个线程池和 count 为 6 的CountDownLatch
对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch
对象的 await()
方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
public class CountDownLatchExample1 {
// 处理文件的数量
private static final int threadCount = 6;
public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建)
ExecutorService threadPool = Executors.newFixedThreadPool(10);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
final int threadnum = i;
threadPool.execute(() -> {
try {
//处理文件的业务操作
//......
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//表示一个文件已经被完成
countDownLatch.countDown();
}
});
}
countDownLatch.await();
threadPool.shutdown();
System.out.println("finish");
}
}
有没有可以改进的地方呢?
可以使用 CompletableFuture
类来改进!Java8 的 CompletableFuture
提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。
🌟有四个线程A、B、C、D、E,现在需要 E线程在 ABCD四个线程结束之后再执行
join 让主线程等待子线程运行结束后再继续运行:join方法中如果传入参数,则表示这样的意思:如果线程A 中掉用线程B的 join(10),则表示线程A 会等待线程B 执行10毫秒,10毫秒过后,A、B线程并行执行。需要注意的是,jdk规定,join(0)的意思不是 线程A等待线程B 0秒,而是线程A 等待线程B 无限时间,直到线程B 执行完毕,即join(0)等价于join()。(其实join()中调用的是join(0))
利用并发包里的 Excutors的 newSingleThreadExecutor产生一个单线程的线程池,而这个线程池的底层原理就是一个先进先出(FIFO)的队列。代码中 executor.submit依次添加了123线程,按照 FIFO的特性,执行顺序也就是123的执行结果,从而保证了执行顺序。
使用 CountDownLatch 控制多个线程执行顺序 cutDown()方法和 await()方法:可以通过调用CounDownLatch对象的cutDown()方法,来使计数减1;如果调用对象上的await()方法,那么调用者就会一直阻塞在这里,直到别人通过cutDown方法,将计数减到0,才可以继续执行。
🌟CountDownLatch代码题
实现一个容器,提供两个方法,add,size ;写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/**
* 使用CountDownLatch 代替wait notify 好处是通讯方式简单,不涉及锁定 Count 值为0时当前线程继续执行,
*/
public class Demo {
volatile List list = new ArrayList();
public void add(int i){
list.add(i);
}
public int size(){
return list.size();
}
public static void main(String[] args) {
Demo t = new Demo();
CountDownLatch countDownLatch = new CountDownLatch(1);
new Thread(() -> {
System.out.println("t2 start");
if(t.size() != 5){
try {
countDownLatch.await();
System.out.println("t2 end");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2").start();
new Thread(()->{
System.out.println("t1 start");
for (int i = 0; i < 10; i++){
t.add(i);
System.out.println("add"+ i);
if(t.size() == 5){
System.out.println("countdown is open");
countDownLatch.countDown();
}
}
System.out.println("t1 end");
},"t1").start();
}
}
❷CyclicBarrier
🌟CyclicBarrier 有什么用?
CountDownLatch
的实现是基于 AQS 的,而CycliBarrier
是基于ReentrantLock
和Condition
的。
CyclicBarrier
循环屏障,主要功能和countDownLatch
类似,也是通过一个计数器,使一个线程等待其他线程各自执行完毕后再执行。但是其可以重复使用(reset)。
🌟CyclicBarrier 的原理是什么?
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
🌟CountDownLatch 和 CyclicBarrier 有什么区别?
CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。
CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
CountDownLatch 应用场景:
1、某一线程在开始运行前等待 n 个线程执行完毕。启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
2、实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。
3、死锁检测。使用 n 个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
CyclicBarrier 应用场景:
CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。
❸Exchanger
🌟Exchanger主要解决什么问题?
Exchanger
用于进行两个线程之间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()
方法交换数据,当一个线程先执行exchange()
方法后会阻塞,它会一直等待第二个线程也执行exchange()
方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
🌟对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
Exchanger是一种线程间安全交换数据的机制。
线程A通过SynchronousQueue
将数据a交给线程B;
线程A通过Exchanger
和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。
可见,SynchronousQueue
是交换一个数据,Exchanger
是交换两个数据。
🌟Exchanger在不同的JDK版本中实现有什么差别?
- 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
- 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量。
⑦并发容器
JDK 提供的这些容器大部分在 java.util.concurrent
包中。
ConcurrentHashMap
: 线程安全的HashMap
CopyOnWriteArrayList
: 线程安全的List
,在读多写少的场合性能非常好,远远好于Vector
。ConcurrentLinkedQueue
: 高效的并发队列,使用链表实现。可以看做一个线程安全的LinkedList
,这是一个非阻塞队列。BlockingQueue
: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。ConcurrentSkipListMap
: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
❶ConcurrentHashMap
🌟什么是ConcurrentHashMap?相比于HashMap和HashTable有什么优势?
在并发场景下如果要保证一种可行的方式是使用
Collections.synchronizedMap()
方法来包装我们的HashMap
。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
CocurrentHashMap
可以看作线程安全且高效的HashMap
,相比于HashMap
具有线程安全的优势,相比于HashTable
具有效率高的优势。
🌟ConcurrentHashMap是如何实现的?
JDK1.7
在JDK1.7版本中,ConcurrentHashMap
的数据结构是由一个Segment
数组和多个HashEntry
数组组成,Segment
存储的是链表数组的形式。
从上图可以看出,ConcurrentHashMap
定位一个元素的过程需要两次Hash的过程,第一次Hash的目的是定位到Segment,第二次Hash的目的是定位到链表的头部。两次Hash所使用的时间比一次Hash的时间要长,但这样做可以在写操作时,只对元素所在的 segment 加锁,不会影响到其他segment,这样可以大大提高并发能力。
JDK1.8
JDK1.8不在采用segment的结构,而是使用Node数组+链表/红黑树的数据结构来实现的(和HashMap
一样,链表节点个数大于8,链表会转换为红黑树)
从上图可以看出,对于ConcurrentHashMap
的实现,JDK1.8的实现方式可以降低锁的粒度,因为JDLK1.7所实现的ConcurrentHashMap
的锁的粒度是基于Segment,而一个Segment包含多个HashEntry。JDLK1.8锁定当前链表或红黑二叉树的首节点。
🌟ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?
final
修饰变量可以保证变量不需要同步就可以被访问和共享,volatile
可以保证内存的可见性,配合CAS操作可以在不加锁的前提支持并发。
🌟ConcurrentHashMap有什么缺点?
因为ConcurrentHashMap
在更新数据时只会锁住部分数据,并不会将整个表锁住,读取的时候也并不能保证读取到最近的更新,只能保证读取到已经顺利插入的数据。
🌟ConcurrentHashMap默认初始容量是多少?每次扩容为原来的几倍?
ConcurrentHashMap默认的初始容量为16,每次扩容为之前的两倍。
ConCurrentHashmap在JDK1.8中,什么情况下链表会转化为红黑树?当链表长度大于8,Node数组数大于64时。
🌟ConCurrentHashMap的key,value是否可以为null?HashMap中的key,value是否可以为null?
ConCurrentHashMap
中的key
和value
为null
会出现空指针异常,而HashMap
中的key
和value
值是可以为null
的。
原因如下:ConCurrentHashMap
是在多线程场景下使用的,如果ConcurrentHashMap.get(key)
的值为null
,那么无法判断到底是key
对应的value
的值为null
还是不存在对应的key
值。而在单线程场景下的HashMap
中,可以使用containsKey(key)
来判断到底是否存在这个key
还是key
对应的value
的值为null
。在多线程的情况下使用containsKey(key)
来做这个判断是存在问题的,因为在containsKey(key)
和ConcurrentHashMap.get(key)
两次调用的过程中,key
的值已经发生了改变。
🌟ConcurrentHashMap在JDK1.7和JDK1.8版本中的区别?
- 实现结构上的不同,JDK1.7是基于Segment实现的,JDK1.8是基于Node数组+链表/红黑树实现的。
- 保证线程安全方面:JDK1.7采用了分段锁的机制,当一个线程占用锁时,会锁住一个Segment对象,不会影响其他Segment对象。JDK1.8则是采用了CAS和
synchronize
的方式来保证线程安全。 - 在存取数据方面:
- JDK1.7中的
put()
方法:- 先计算出
key
的hash
值,利用hash
值对segment数组取余找到对应的segment对象。 - 尝试获取锁,失败则自旋直至成功,获取到锁,通过计算的
hash
值对hashentry数组进行取余,找到对应的entry对象。 - 遍历链表,查找对应的
key
值,如果找到则将旧的value直接覆盖,如果没有找到,则添加到链表中。(JDK1.7是插入到链表头部,JDK1.8是插入到链表尾部,这里可以思考一下为什么这样)
- 先计算出
- JDK1.8中的
put()
方法:- 计算
key
值的hash
值,找到对应的Node
,如果当前位置为空则可以直接写入数据。 - 利用CAS尝试写入,如果失败则自旋直至成功,如果都不满足,则利用
synchronized
锁写入数据。
- 计算
- JDK1.7中的
🌟ConcurrentHashMap迭代器是强一致性还是弱一致性?
与HashMap不同的是,ConcurrentHashMap
迭代器是弱一致性。
弱一致性:当ConcurrentHashMap
的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put()
方法将一个元素加入到底层数据结构后,get()
可能在某段时间内还看不到这个元素。
这样设计主要是为ConcurrenthashMap
的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以ConcurrentHashMap
是支持在迭代过程中,向map中添加元素的,而HashMap
这样操作则会抛出异常。
❷CopyOnWriteArrayList
🌟什么是CopyOnWriteArrayList?
CopyOnWriteArrayList
与ReentrantReadWriteLock
读写锁的思想非常类似,也就是读读共享、写写互斥、读写互斥、写读互斥。JDK 中提供了CopyOnWriteArrayList
类相比于读写锁的思想又更进一步。
CopyOnWriteArrayList 采用了写时复制的思想,增删改操作会将底层数组拷贝一份,在新数组上执行操作,不影响其它线程的并发读,实现读写分离
🌟CopyOnWriteArrayList 是如何做到的?
CopyOnWriteArrayList的实现原理主要分为两个方面,一是利用可重入锁实现线程安全,二是通过复制数组实现读写分离。
在CopyOnWriteArrayList中,每次写操作都会先获取可重入锁,然后将当前数组复制一份,在新数组上执行操作,修改后再将新的数组赋值给原来的引用,在修改完成后释放锁。由于读操作不会对原数组进行修改,所以读操作可以直接对原来的数组进行读取,无需加锁。这样就实现了读写分离的效果,可以在不影响正在进行的读操作的情况下进行写操作。
🌟CopyOnWriteArrayList有何缺陷,说说其应用场景?
- 由于写操作的时候,需要拷贝数组,会消耗内存,性能较低,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
- 不能保证数据的实时一致性,由于写操作的结果只会对新的数组产生影响,所以在多线程环境中,读取到的数据可能不是最新的。因此,CopyOnWriteArrayList适用于读多写少且对实时性要求不高的场景。
❸ConcurrentLinkedQueue
Java 提供的线程安全的 Queue
可以分为阻塞队列和非阻塞队列
- 阻塞队列的典型例子是
BlockingQueue
,阻塞队列可以通过加锁来实现 - 非阻塞队列的典型例子是
ConcurrentLinkedQueue
, 非阻塞队列可以通过 CAS 操作实现。
ConcurrentLinkedQueue
这个队列使用链表作为其数据结构.ConcurrentLinkedQueue
应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
ConcurrentLinkedQueue
主要使用 CAS 非阻塞算法来实现线程安全。
ConcurrentLinkedQueue
适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue
来替代。
❹BlockingQueue
BlockingQueue是Java中一个线程安全的队列接口,它扩展了Queue接口,提供了阻塞操作,可以在队列为空或者队列已满时自动阻塞等待。
BlockingQueue在多线程编程中广泛应用,特别是在生产者-消费者模式中。其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
Java中提供了多种BlockingQueue
的实现类,如ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等。其中,ArrayBlockingQueue
和LinkedBlockingQueue
是最常用的实现类。
1)ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
2)LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool() 使用了这个队列。
3)SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列。
4)PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
❺ConcurrentSkipListMap
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。
但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 。跳表的本质是同时维护了多个链表,并且链表是分层的。
📚参考资料
- ♥Java并发知识体系详解♥ | Java 全栈知识体系 (pdai.tech)
- 路人张的面试笔记 (mianshi.online)
- Java并发知识点总结 (github.com)
- 一文让你彻底明白ThreadLocal
- ThreadLocal(史上最全)
- CAS操作的底层原理以及应用详解
❤️Sponsor
您的支持是我不断前进的动力,如果您感觉本文对您有所帮助的话,可以考虑打赏一下本文,用以维持本博客的运营费用,拒绝白嫖,从你我做起!🥰🥰🥰
支付宝 | 微信 |