①线程
❶基础
🌟线程和进程的区别
进程:是程序运行和资源分配的基本单位
- 拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
线程:是 CPU 调度和分派的基本单位,是进程的一个实体
- 拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。
一个程序至少有一个进程,一个进程至少有一个线程。进程在执行过程中拥有独立的内存单元,而多个线程共享内存资源,减少切换次数,从而效率更高。
🌟说说协程和线程区别
协程是一种比线程更加轻量级的存在。协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态中执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。因此协程的开销远远小于线程的开销。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其它地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
- 一个线程可以有多个协程,一个进程也可以单独拥有多个协程;
- 线程进程都是同步机制,而协程则是异步机制;
- 协程能保留上一次调用的状态,每次重入时,就相当于进入上一次调用的状态;
🌟同步和异步的区别
- 同步 : 发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步 :调用在发出之后,不用等待返回结果,该调用直接返回。
🌟守护线程和用户线程的区别
用户线程:平时使用到的线程均为用户线程。
守护线程:用来服务用户线程的线程,例如垃圾回收线程。
任何线程都可以设置为守护线程和用户线程,通过方法Thread.setDaemon(true/false)
设置
- true则是将该线程设置为守护线程,false则是将该线程设置为用户线程。
- 必须在
Thread.start()
之前调用,否则运行时会抛出异常。
守护线程和用户线程的区别主要在于Java虚拟机是否存活。
- 用户线程:当任何一个用户线程未结束,Java虚拟机则不会结束。
- 守护线程:如果只剩守护线程未结束,Java虚拟机结束。
🌟线程的上下文切换
CPU会通过时间片分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但切换前会保存上一个任务的状态,因为下次切换回这个任务时还要加载这个任务的状态继续执行,从任务保存到再加载的过程就是一次上下文切换。
🌟线程状态及转换
线程的生命周期和状态
在 Java API 中 java.lang.Thread.State
这个枚举中给出了六种线程状态:
线程状态 | 导致状态发生条件 |
---|---|
NEW | 初始状态,线程刚被创建,但是并未启动,还没调用 start 方法,只有线程对象,没有线程特征 |
RUNNABLE | 运行状态,包含就绪和运行中两种状态 |
BLOCKED | 阻塞状态 |
WAITING | 等待状态 |
TIME_WAITING | 超时等待状态,和等待状态不同的是,它可以在制定的时间自行返回 |
TERMINATED | 终止状态,线程运行结束 |
![]() |
![]() |
---|
🌟线程死锁是如何产生的,如何避免
死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时被阻塞。
死锁产生的条件:
- 互斥条件:一个资源在同一时刻只由一个线程占用。
- 请求与保持条件:一个线程在请求被占资源时发生阻塞,并对已获得的资源保持不放。
- 循环等待条件:发生死锁时,所有的线程会形成一个死循环,一直阻塞。
- 不剥夺条件:线程已获得的资源在未使用完不能被其他线程剥夺,只能由自己使用完释放资源。
避免死锁的方法主要是破坏死锁产生的条件。
- 破坏互斥条件:这个条件无法进行破坏,锁的作用就是使他们互斥。
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏循环等待条件:按顺序来申请资源。
- 破坏不剥夺条件:线程在申请不到所需资源时,主动放弃所持有的资源。
🌟死锁、活锁、饥饿有什么区别?
活锁:任务或者执行者没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败、尝试、失败。
活锁和死锁的区别:
- 活锁是在不断地尝试、死锁是在一直等待。
- 活锁有可能自行解开、死锁无法自行解开。
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源, 导致一直无法执行的状态。
死锁、饥饿的区别:饥饿可自行解开,死锁不行。
❷使用
🌟创建线程的几种方式
- 继承 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()
不会释放占有的锁,而wait()
会释放占有的锁 。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法,或者使用wait(long timeout)
超时后线程会自动苏醒。sleep()
方法执行完成后,线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。
🌟sleep() 和 yield() 有什么不同
-
sleep()
方法会使得当前线程暂停指定的时间,没有消耗CPU时间片。 -
sleep()
一定会完成给定的休眠时间,yield()
不一定能完成。 -
sleep()
使得线程进入到阻塞状态,yield()
只是对CPU进行提示,如果CPU没有忽略这个提示,会使得线程上下文的切换,进入到就绪状态。 -
sleep()
需要抛出InterruptedException,而yield()
方法无需抛出异常。
🌟为什么wait(),notify()必须在同步方法或者同步块中被调用
因为wait()暂停的是持有锁的对象,notify()或notifyAll()唤醒的是等待锁的对象。
所以wait()、notify()、notifyAll()都需要线程持有锁的对象,进而需要在同步方法或者同步块中被调用。
🌟为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中
等待和唤醒必须是同一锁。而锁可以是任意对象,所以可以被任意对象调用的方法是定义在Object类中。
🌟Java如何实现两个线程之间的通信和协作
-
syncrhoized
加锁的线程的Object
类的wait()
/notify()
/notifyAll()
- 使用
volatile
关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。 -
ReentrantLock
类加锁的线程的Condition
类的await()
/signal()
/signalAll()
- 基于
LockSupport
实现线程间的阻塞和唤醒。LockSupport
是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。 - 使用JUC工具类
CountDownLatch
。jdk1.5 之后在java.util.concurrent
包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch
基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。 - 通过管道进行线程间通信:1)字节流;2)字符流 ,就是一个线程发送数据到输出管道,另一个线程从输入管道读数据。
🌟什么是线程同步?什么是线程互斥?如何实现的?
线程互斥是指某一个资源只能被一个访问者访问,具有唯一性和排他性。但访问者对资源访问的顺序是乱序的。
线程同步是指在互斥的基础上使得访问者对资源进行有序访问。 需要等待前面结果返回,才能继续运行
线程同步的实现方法:
- 同步方法
- 同步代码块
-
wait()
和notify()
- 使用volatile实现线程同步
- 使用重入锁实现线程同步
- 使用局部变量实现线程同步
- 使用阻塞队列实现线程同步
🌟如何保证线程的运行安全
线程安全问题主要体现在原子性、可见性和有序性。
- 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。线程切换带来的原子性问题。
- 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。
- 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。
解决方法:
- 原子性问题:可用JDK
Atomic
开头的原子类、synchronized
、LOCK
来解决 - 可见性问题:可用
synchronized
、volatile
、LOCK
来解决 - 有序性问题:可用
Happens-Before
规则来解决
🌟线程安全的实现方法
- 互斥同步:synchronized , ReentrantLock
- 非阻塞同步:CAS,Atomic类
- 无同步方案:栈封闭,本地存储(ThreadLocal),可重入代码
🌟如何停止一个正在运行的线程
- 中断:
Interrupt
方法中断线程 - 使用
volatile boolean
标志位停止线程:在线程中设置一个boolean
标志位,同时用volatile
修饰保证可见性,在线程里不断地读取这个值,其他地方可以修改这个boolean
值。 - 使用
stop()
方法停止线程,但该方法已经被废弃。因为这样线程不能在停止前保存数据,会出现数据完整性问题。
🌟说下你对 Java 内存模型(JMM)的理解?
处理器和内存不是同数量级,所以需要在中间建立中间层,也就是高速缓存,这会引出缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),有可能操作同一位置引起各自缓存不一致,这时候需要约定协议在保证一致性。
Java 内存模型(Java Memory Model,JMM):屏蔽掉了各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果
主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每个线程有自己的工作内存(Working Memory),保存主内存副本和自己私有变量,不同线程不能访问工作内存中的变量。线程间变量值的传递需要通过主内存来完成。
②关键字
❶synchronized
synchronized解决的是多个线程之间访问资源的同步性,synchronized可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
🌟synchronized的用法有哪些?
如何在项目中使用 synchronized 的?
synchronized关键字的使用方法?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
🌟synchronized三大特性是什么?
synchronized的作用有哪些
可见性和原子性有什么区别
原子性:一个或多个操作要么全部执行成功,要么全部执行失败。确保线程互斥的访问同步代码;
可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。保证共享变量的修改能够及时可见;
有序性:程序的执行顺序会按照代码的先后顺序执行。有效解决重排序问题。
🌟synchronized可实现什么类型的锁?
悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
独占锁或者排他锁: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
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
🌟JDK1.6后synchronized做了哪些优化
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
36.jdk1.6为什么要对synchronized进行优化?做了哪些优化? - 路人张的面试笔记 (mianshi.online)
说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?-帅地玩编程 (iamshuaidi.com)
🌟偏向锁、轻量级锁、重量级锁的对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要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的区别?
- Lock是显示锁,需要手动开启和关闭。synchronized是隐士锁,可以自动释放锁。
- Lock是一个接口,是JDK实现的。synchronized是一个关键字,是依赖JVM实现的。
- Lock是可中断锁,synchronized是不可中断锁,需要线程执行完才能释放锁。
- 发生异常时,Lock不会主动释放占有的锁,必须通过unlock进行手动释放,因此可能引发死锁。synchronized在发生异常时会自动释放占有的锁,不会出现死锁的情况。
- Lock可以判断锁的状态,synchronized不可以判断锁的状态。
- Lock实现锁的类型是可重入锁、公平锁。synchronized实现锁的类型是可重入锁,非公平锁。
- Lock适用于大量同步代码块的场景,synchronized适用于少量同步代码块的场景。
❷volatile
🌟volatile的特性有哪些?
并发编程的三大特性为可见性、有序性和原子性。通常来讲
volatile
可以保证可见性和有序性。
- 可见性:
volatile
可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。 - 有序性:
volatile
会通过禁止指令重排序进而保证有序性。 - 原子性:对于单个的
volatile
修饰的变量的读写是可以保证原子性的,但对于i++
这种复合操作并不能保证原子性。这句话的意思基本上就是说volatile
不具备原子性了。
🌟volatile的作用是什么?
- 防重排序
- 如果我们将变量声明为
volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
- 如果我们将变量声明为
- 实现可见性
- 保证原子性:单次读/写
🌟voliatile的实现原理?
volatile 可见性实现
volatile 有序性实现
🌟volatile实现内存可见性原理?
导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,例如上面所说线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?
volatile
可以保证内存可见性的关键是volatile
的读/写实现了缓存一致性,缓存一致性的主要内容为:
- 每个处理器会通过嗅探总线上的数据来查看自己的数据是否过期,一旦处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存设为无效状态。此时,如果处理器需要获取这个数据需重新从主内存将其读取到本地内存。
- 当处理器写数据时,如果发现操作的是共享变量,会通知其他处理器将该变量的缓存设为无效状态。
那缓存一致性是如何实现的呢?可以发现通过volatile
修饰的变量,生成汇编指令时会比普通的变量多出一个Lock
指令,这个Lock
指令就是volatile
关键字可以保证内存可见性的关键,它主要有两个作用:
- 将当前处理器缓存的数据刷新到主内存。
- 刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效。
🌟Java内存的可见性问题
可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。
🌟如何保证变量的可见性?
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
🌟volatile如何实现有序性
为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
happens-before等
🌟volatile是如何实现可见性的? 内存屏障。
🌟volatile能保证原子性吗?
volatile
关键字能保证变量的可见性、有序性,但不能保证对变量的操作是原子性的。
🌟i++为什么不能保证原子性?
🌟如何保证多线程下 i++ 结果正确
- 使用循环CAS+volatile,实现 i++原子操作;
- 使用 synchronized,实现 i++原子操作;
- 使用 Lock锁机制,实现i++原子操作;
- Semaphore构造方法中传入的参数是1的时候,此时线程并发数最多是1个,即是线程安全的,这种方式也可以做到现场互斥。
- 使用 AtomicInteger:由硬件提供原子操作指令实现的;
🌟volatile能使一个非原子操作变成一个原子操作吗?
volatile
只能保证可见性和有序性,但可以保证64位的long
型和double
型变量的原子性。
对于32位的虚拟机来说,每次原子读写都是32位的,会将long
和double
型变量拆分成两个32位的操作来执行,这样long
和double
型变量的读写就不能保证原子性了,而通过volatile
修饰的long和double型变量则可以保证其原子性。
🌟32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?
对于32位的虚拟机来说,每次原子读写都是32位的,会将long
和double
型变量拆分成高32位和低32位的两个操作来执行,这样long
和double
型变量的读写就不能保证原子性了,而通过volatile
修饰的long和double型变量则可以保证其原子性。
目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。
🌟说下volatile的应用场景?
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
- 例子 1: 单例模式
- 例子2: volatile bean
🌟如何禁止指令重排序?
如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
🌟双重检验锁(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
还未被初始化。
🌟为什么要进行指令重排?
计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。
指令重排序一般分为编译器优化重排、指令并行重拍和内存系统重排三种。
- 编译器优化重排:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序。
- 指令并行重排:现代处理器多采用指令级并行技术来将多条指令重叠执行。对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
- 内存系统重排:因为处理器使用缓存和读/写缓冲区,使得加载(load)和存储(store)看上去像是在乱序执行。
注:简单解释下数据依赖性:如果两个操作访问了同一个变量,并且这两个操作有一个是写操作,这两个操作之间就会存在数据依赖性,例如:
a = 1;
b = a;
这三种指令重排说明了一个问题,就是指令重排在单线程下可以提高代码的性能,但在多线程下可以会出现一些问题。
🌟指令重排会带来什么问题?
指令重排在单线程下可以提高代码的性能,但在多线程下会破坏程序的语义
🌟as-if-serial规则和happens-before规则的区别?
🌟内存屏障的原理
🌟编译器对内存屏障插入策略的优化
❸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域重排序规则?
🌟说说final的原理?
🌟使用 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在Java中的应用
🌟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则无法保证操作的原子性,这时候就需要用锁了。在看《Java并发编程的艺术》时,里面提到了一个办法可以参考一下,就是将多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a
,合并成ij=2a
,然后用CAS来操作ij
,从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
那么CAS有什么优点呢?在并发量不是很大时提高效率。
🌟并发队列的无锁化可以怎么实现?
Compare And Swap
,比较与交换,Java中可以通过CAS操作来保证原子性,是乐观锁的主要实现方式。
🌟说下对悲观锁和乐观锁的理解?
- 悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如:行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
- 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 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 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)。
1、对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
2、对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
🌟JVM 中的 CAS 是怎么实现的?操作系统中的呢?
。。。。
CAS的全称为Compare And Swap,直译就是比较交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg指令,就是说CAS是靠硬件实现的,从而在硬件层面提升效率。
❷Atomic
🌟请阐述你对Unsafe类的理解?
Unsafe 是 CAS 的核心类,由于 Java 无法直接访问底层系统,需要通过本地(Native)方法来访问
Unsafe 类存在 sun.misc 包,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,其中所有方法都是 native 修饰的,都是直接调用操作系统底层资源执行相应的任务,基于该类可以直接操作特定的内存数据,其内部方法操作类似 C 的指针,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。
Java魔法类:Unsafe应用解析 - 美团技术团队 (meituan.com)
🌟简单说下对 Java 中的原子类的理解?
包含13个,4组分类,说说作用和使用场景。
Atomic 指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
原子操作类是CAS在Java中的应用,从JDK1.5开始提供了java.util.concurrent.atomic
包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。Atomic包里的类基本都是使用Unsafe
实现的包装类。
JUC包中的4种原子类
- 基本类型:使用原子的方式更新基本类型
-
AtomicInteger
:整形原子类 -
AtomicLong
:长整型原子类 -
AtomicBoolean
:布尔型原子类
-
- 数组类型:使用原子的方式更新数组里的某个元素
-
AtomicIntegerArray
:整形数组原子类 -
AtomicLongArray
:长整形数组原子类 -
AtomicReferenceArray
:引用类型数组原子类
-
- 引用类型:
-
AtomicReference
:引用类型原子类,存在ABA问题 -
AtomicStampedReference
:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。 -
AtomicMarkableReference
:原子更新带有标记位的引用类型
-
- 原子更新字段类
-
AtomicIntegerFieldUpdater
:原子更新整型的字段的更新器。 -
AtomicLongFieldUpdater
:原子更新长整型字段的更新器。 -
AtomicReferenceFieldUpdater
:引用类型更新器原子类
-
🌟AtomicInteger底层实现? CAS+volatile
🌟atomic 的原理是什么?
Atomic 包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
Atomic 系列的类中的核心方法都会调用 unsafe 类中的几个本地方法。我们需要先知道一个东西就是 Unsafe 类,全名为:sun.misc.Unsafe,这个类包含了大量的对 C 代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过 unsafe 分配内存的时候,如果自己指定某些区域可能会导致一些类似 C++ 一样的指针越界到其他进程的问题。
❸ThreadLocal
🌟什么是ThreadLocal?
ThreadLocal
是 JDK java.lang
包下的一个类,ThreadLocal
为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,并且不会和其他线程的局部变量冲突,实现了线程间的数据隔离。分配在堆内的 TLAB 中
🌟ThreadLocal 解决了哪些问题
ThreadLocal 有什么用?用来解决什么问题的?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
ThreadLocal
类正是为了解决这样的问题。 ThreadLocal
类主要解决的就是让每个线程绑定自己的值,实现了线程间的数据隔离
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal 作用:
线程并发:应用在多线程并发的场景下
传递数据:通过 ThreadLocal 实现在同一线程不同函数或组件中传递公共变量,减少传递复杂度
线程隔离:每个线程的变量都是独立的,不会互相影响
🌟ThreadLocal原理?ThreadLocal是如何实现线程隔离的?
JDK8 以后:每个 Thread 内部维护一个 ThreadLocalMap,这个 Map 的 key 是 ThreadLocal 实例本身,value 是真正要存储的值
- Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值
- 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成副本的隔离,互不干扰
每个线程都有一个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 类维护了一个静态变量 nextHashCode,每一个 threadlocal 实例都会获得属于自己的 hashcode,这是通过对 nextHashCode 不断加 0x61c88647 实现的(为啥是0x61c88647 呢我也不知道,可能会比较均匀吧)。ThreadLocalMap 使用线性探测法解决哈希冲突:每个 threadlocal 在插入时会检查数组的当前位置的情况,如果为 null 直接插入(为 null 表示弱引用被 gc 掉了),不为 null 则顺序查找下一个,直到找到空位置为止。
当 ThreadLocalMap 内元素超过最大大小的 3/4 时会进行扩容,新 map 大小为 2 倍。扩容时从第一个entry 开始,依次往新 map 里迁移,当中会丢弃掉 key 为 null 的。
🌟如何实现父子线程间局部变量共享
实现父子线程间的局部变量共享需要追溯到 Thread 对象的构造方法:在最终执行的构造方法中,有这样一个判断:如果当前父线程(创建子线程的线程)的 inheritableThreadLocals 属性不为 NULL,就会将当下父线程的 inheritableThreadLocals 属性复制给子线程的 inheritableThreadLocals 属性。
需要注意的是,复制父线程共享变量的时机是在创建子线程时,如果在创建子线程后父线程再往 InheritableThreadLocal 类型的对象中设置内容,将不再对子线程可见。
④线程池
❶基础
🌟什么是线程池?
线程池:一个容纳多个线程的容器,容器中的线程可以重复使用,省去了频繁创建和销毁线程对象的操作
线程池的核心思想:线程复用,同一个线程可以被重复使用,来处理多个任务
池化技术 (Pool) :一种编程技巧,核心思想是资源复用,在请求量大时能优化应用性能,降低系统频繁建连的资源开销
🌟为什么要用线程池?
为什么要使用Executor线程池框架呢?说下对线程池的理解?
线程池能够对线程进行统一分配,调优和监控:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
🌟线程池创建的方法
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
线程池的常用创建方式主要有两种,通过通过ThreadPoolExecutor 的构造方法创建和Executors工厂方法创建
方式一:通过 ThreadPoolExecutor 的构造方法实现:
方式二:通过 Executor 框架的工具类 Executors 来实现:
可以创建三种类型的 ThreadPoolExecutor:
- FixedThreadPool :
newFixedThreadPool
方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 - SingleThreadExecutor:
newSingleThreadExecutor
方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 - CachedThreadPool:
newCachedThreadPool
方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
🌟为什么不允许使用Executors创建线程池? 那么推荐怎么使用呢?
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
推荐方式 1
首先引入:commons-lang3包
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,
new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());
推荐方式 2
首先引入:com.google.guava包
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();
//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
// excute
pool.execute(()-> System.out.println(Thread.currentThread().getName()));
//gracefully shutdown
pool.shutdown();
推荐方式 3
spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可
<bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<property name="corePoolSize" value="10" />
<property name="maxPoolSize" value="100" />
<property name="queueCapacity" value="2000" />
<property name="threadFactory" value= threadFactory />
<property name="rejectedExecutionHandler">
<ref local="rejectedExecutionHandler" />
</property>
</bean>
//in code
userThreadPool.execute(thread);
🌟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(饱和策略)
:线程池任务队列超过maxinumPoolSize
之后的拒绝策略。
🌟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
策略:直接丢弃新任务。 -
DiscardOleddestPolicy
策略:丢弃最早的未处理的任务请求。
当然,也可以根据应用场景需要来实现 RejectedExecutionHandler 接口自定义策略。如记录日志或持久化存储不能处理的任务。
补充:其他框架拒绝策略
- Dubbo:在抛出 RejectedExecutionException 异常前记录日志,并 dump 线程栈信息,方便定位问题
- Netty:创建一个新线程来执行任务
- ActiveMQ:带超时等待(60s)尝试放入队列
- PinPoint:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略
🌟线程池的运行流程
假如我把 corepoolsize 和 maxpoolsize 分别设置为 5 和 10,假设任务计算时间很长,我往里面连续放入 20 个任务,按时间顺序会发生什么
创建线程池创建后提交任务的流程如下图所示:
创建线程池时,没有线程,等待提交过来的任务请求(懒惰),调用 execute 方法才会创建线程【①】
当调用 execute() 方法添加一个请求任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务【②】
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列【③】
- 如果这时队列满了且正在运行的线程数量还小于 maximumPoolSize,那么会创建非核心线程立刻运行这个任务,对于阻塞队列中的任务不公平。这是因为创建每个 Worker(线程)对象会绑定一个初始任务,启动 Worker 时会优先执行【④】
- 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行【⑤】
当一个线程完成任务时,会从队列中取下一个任务来执行【take/poll】
当一个线程空闲超过一定的时间(keepAliveTime)时,线程池会判断:如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉,所以线程池的所有任务完成后最终会收缩到 corePoolSize 大小
🌟简要说下线程池的任务执行机制?
execute –> addWorker –>runworker (getTask)
线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 从Woker类的构造方法实现可以发现: 线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;
🌟线程池中任务是如何提交的?
方式一:提交无返回值的任务 execute()
方式二:提交有返回值的任务 submit()
submit():提交任务,把 Runnable 或 Callable 任务封装成 FutureTask 执行,可以通过方法返回的任务对象,调用 get 阻塞获取任务执行的结果或者异常
execute():执行任务,但是没有返回值,没办法获取任务执行结果,出现异常会直接抛出任务执行时的异常。根据线程池中的线程数,选择添加任务时的处理方式
🌟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 |
会直接抛出任务执行时的异常 | 会吞掉异常,可通过 Future 的 get() 方法将任务执行时的异常重新抛出 |
🌟线程池中任务是如何关闭的?
线程池自动关闭的两个条件:1、线程池的引用不可达;2、线程池中没有线程;
shutdown()
:线程池状态变为 SHUTDOWN,等待任务执行完后关闭线程池,不会接收新任务,但已提交任务会执行完,而且也可以添加线程(不绑定任务)
shutdownNow()
:线程池状态变为 STOP,用 interrupt 中断正在执行的任务,直接关闭线程池,不会接收新任务,会将队列中的任务返回
🌟线程池中的的线程数一般怎么设置?需要考虑哪些问题?
配置线程池需要考虑哪些因素?
线程池里的具体的数量,如何去定
从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且尽可能地使用有界的工作队列。
创建多大容量的线程池合适?
一般来说池中总线程数是核心池线程数量两倍,确保当核心池有线程停止时,核心池外有线程进入核心池
过小会导致程序不能充分地利用系统资源、容易导致饥饿
过大会导致更多的线程上下文切换,占用更多内存
上下文切换:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态,任务从保存到再加载的过程就是一次上下文切换
性质不同的任务可用使用不同规模的线程池分开处理:(核心线程数常用公式)
- 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 操作完成上。
🌟如何监控线程池的状态?
可以使用ThreadPoolExecutor以下方法:
getTaskCount()
Returns the approximate total number of tasks that have ever been scheduled for execution.getCompletedTaskCount()
Returns the approximate total number of tasks that have completed execution. 返回结果少于getTaskCount()。getLargestPoolSize()
Returns the largest number of threads that have ever simultaneously been in the pool. 返回结果小于等于maximumPoolSizegetPoolSize()
Returns the current number of threads in the pool.getActiveCount()
Returns the approximate number of threads that are actively executing tasks.
🌟Java 自带的线程池有哪些?
为什么不推荐用 Java 预设的线程池
1、CachedThreadPool:这种线程池没有等待队列(队列长度为 0)内部没有核心线程,线程的数量是有没限制的。
2、FixedThreadPool:该线程池的最大线程数等于核心线程数。等待队列长度无限。
3、SingleThreadPool:有且仅有一个工作线程执行任务。等待队列长度不限。
4、ScheduledThreadPool:设置了核心线程数。等待队列长度无限,会按任务启动时间排序,若不到时间则会阻塞。
不要使用 Java 自带的线程池。因为 CachedThreadPool 会无限创建线程,FixedThreadPool、SingleThreadPool、ScheduledThreadPool 会无限增加队列长度,会导致线程数溢出或内存溢出。
🌟ScheduledThreadPoolExecutor要解决什么样的问题?
ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。
🌟ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性?
和 ThreadPoolExecutor 相比,它还具有以下几种特性:
- 使用专门的任务类型—
ScheduledFutureTask
来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。 - 使用专门的存储队列—
DelayedWorkQueue
来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法)。 - 支持可选的
run-after-shutdown
参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。
🌟ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类?
ScheduledThreadPoolExecutor 内部构造了两个内部类 ScheduledFutureTask
和 DelayedWorkQueue
:
ScheduledFutureTask
: 继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务。DelayedWorkQueue
: 这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。
🌟scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么?
ScheduledFuture<?> scheduleAtFixedRate(Runnable/Callable<V>, long initialDelay, long period, TimeUnit unit)
:定时执行周期任务,不考虑执行的耗时,参数为初始延迟时间、间隔时间、单位。一次任务的启动到下一次任务的启动之间只要等于间隔时间,抢占到 CPU 就会立即执行ScheduledFuture<?> scheduleWithFixedDelay(Runnable/Callable<V>, long initialDelay, long delay, TimeUnit unit)
:定时执行周期任务,考虑执行的耗时,参数为初始延迟时间、间隔时间、单位。一次任务的结束到下一次任务的启动之间等于间隔时间,抢占到 CPU 就会立即执行,这个方法才是真正的设置两个任务之间的间隔
🌟为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
例如: 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
🌟Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor?
newScheduledThreadPool: 可指定核心线程数的线程池。
newSingleThreadScheduledExecutor: 只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。
❷Fork/Join
🌟说下对 Fork和Join 并行计算框架的理解?
Fork/Join 并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”:将一个大的任务拆分成小的子任务的结果聚合起来从而得到最终结果。
Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制,能够让所有的线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的情况,所以性能很好。
ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。
🌟Fork/Join主要用来解决什么样的问题?
Fork/Join框架是Java并发工具包中的一种可以将一个大任务拆分为很多小任务来异步执行的工具,自JDK1.7引入。
Fork/Join:线程池的实现,体现是分治思想,适用于能够进行任务拆分的 CPU 密集型运算,用于并行计算
🌟Fork/Join框架主要包含哪三个模块? 模块之间的关系是怎么样的?
- 任务对象:
ForkJoinTask
(包括RecursiveTask
、RecursiveAction
和CountedCompleter
) - 执行Fork/Join任务的线程:
ForkJoinWorkerThread
- 线程池:
ForkJoinPool
这三者的关系是: ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。
🌟有哪些JDK源码中使用了Fork/Join思想?
我们常用的数组工具类 Arrays 在JDK 8之后新增的并行排序方法(parallelSort)就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的函数式方法(如forEach等)也有运用。
🌟写一个例子: 用ForkJoin方式实现1+2+3+…+100000?
public class Test {
static final class SumTask extends RecursiveTask<Integer> {
private static final long serialVersionUID = 1L;
final int start; //开始计算的数
final int end; //最后计算的数
SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
//如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果
if(end - start < 1000) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for(int i = start; i <= end; i++)
sum += i;
return sum;
}
//如果计算量大于1000,那么拆分为两个任务
SumTask task1 = new SumTask(start, (start + end) / 2);
SumTask task2 = new SumTask((start + end) / 2 + 1, end);
//执行任务
task1.fork();
task2.fork();
//获取任务执行的结果
return task1.join() + task2.join();
}
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTask(1, 10000);
pool.submit(task);
System.out.println(task.get());
}
}
/**
ForkJoinPool-1-worker-1 开始执行: 1-625
ForkJoinPool-1-worker-7 开始执行: 6251-6875
ForkJoinPool-1-worker-6 开始执行: 5626-6250
ForkJoinPool-1-worker-10 开始执行: 3751-4375
ForkJoinPool-1-worker-13 开始执行: 2501-3125
ForkJoinPool-1-worker-8 开始执行: 626-1250
ForkJoinPool-1-worker-11 开始执行: 5001-5625
ForkJoinPool-1-worker-3 开始执行: 7501-8125
ForkJoinPool-1-worker-14 开始执行: 1251-1875
ForkJoinPool-1-worker-4 开始执行: 9376-10000
ForkJoinPool-1-worker-8 开始执行: 8126-8750
ForkJoinPool-1-worker-0 开始执行: 1876-2500
ForkJoinPool-1-worker-12 开始执行: 4376-5000
ForkJoinPool-1-worker-5 开始执行: 8751-9375
ForkJoinPool-1-worker-7 开始执行: 6876-7500
ForkJoinPool-1-worker-1 开始执行: 3126-3750
50005000
**/
⑤JUC锁
❶AQS
🌟AQS 是什么?
说下对同步器 AQS 的理解?
AQS 的全称为 AbstractQueuedSynchronizer
,抽象队列同步器。这个类在 java.util.concurrent.locks
包下面。是一个用来构建锁和同步器的框架,像ReentrantLock,Semaphore,FutureTask,ReentrantReadWriteLock,SynchronousQueue都是基于AQS实现的。
简单来说,AQS就是维护了一个共享资源,然后使用队列来保证线程排队获取资源的一个过程。
🌟AQS 的原理是什么?
AQS 核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置锁定状态
如果请求的共享资源被占用,AQS 用 CLH 队列锁 实现了线程阻塞等待以及被唤醒时锁分配的机制,将暂时获取不到锁的线程加入到队列中
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的资源共享方式有哪些?
- Exclusive:独占,只有一个线程可以执行,例如ReentrantLock,又可分为公平锁和非公平锁
- Share:共享,多个线程可同时执行,如:CountDownLatch、Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock。
🌟AQS底层使用了什么样的设计模式?
模板, 共享锁和独占锁在一个接口类中。
🌟如何使用AQS自定义同步器?
AQS 底层使用了模板方法模式,你能说出几个需要重写的方法吗?
AQS的底层使用了模板方法模式,自定义同步器只需要两步:
第一,继承AbstractQueuedSynchronizer
第二,重写以下几种方法:
isHeldExclusively() :该线程是否正在独占资源。只有用到 condition 才需要去实现它。
tryAcquire(int) :独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
tryRelease(int) :独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
tryAcquireShared(int) :共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int) :共享方式。尝试释放资源,成功则返回 true,失败则返回 false。
🌟AQS 共享锁和独占锁的区别?
共享式与独占式的最主要区别在于:
- 独占式同一时刻只能有一个线程获取同步状态
- 共享式同一时刻可以有多个线程获取同步状态
例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。
acquire 和 acquireShared 的区别:acquireShared 在获取锁后会调用 doReleaseShared() 唤醒下一个节点;acquire 不会,只会唤醒一个
release 和 releaseShared 的区别:releaseShared需要确保线程安全,因为可能会有几个线程同时释放;release不需要保证线程安全,因为一定是独占的
❷ReentrantLock
🌟ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程ID和当前请求的线程ID是否 一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。
🌟ReentrantLock的核心是AQS,那么它怎么来实现的? 继承
通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。
所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持。
ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。
- ReentrantLock是如何实现公平锁的? FairSync
- ReentrantLock是如何实现非公平锁的? UnFairSync
- ReentrantLock默认实现的是公平还是非公平锁?非公平锁
🌟为了有了ReentrantLock还需要ReentrantReadWriteLock?
读锁和写锁分离: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
本地线程计数器ThreadLocalHoldCounter是用来做什么的?本地线程计数器,与对象绑定(线程-》线程重入的次数)
写锁的获取与释放是怎么实现的? tryAcquire/tryRelease
读锁的获取与释放是怎么实现的? tryAcquireShared/tryReleaseShared
❸Semaphore
🌟Semaphore 有什么用?
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;
每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。
然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。
Semaphore 经常用于限制获取某种资源的线程数量。当然一次也可以一次拿取和释放多个许可证,不过一般没有必要这样做。除了 acquire方法(阻塞)之外,另一个比较常用的与之对应的方法是 tryAcquire 方法,该方法如果获取不到许可就立即返回 false。
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
🌟Semaphore 的原理是什么?
Semaphore
是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
,你可以将 permits
的值理解为许可证的数量,只有拿到许可证的线程才能执行。
调用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常用方法有哪些? 如何实现线程同步和互斥的?
acquire函数
release函数
🌟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用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用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()
阻塞当前线程。
🌟Thread.sleep()和LockSupport.park()的区别
LockSupport.park()还有几个兄弟方法:parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。
相同点:都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
区别:
Thread.sleep()
没法从外部唤醒,只能自己醒过来;LockSupport.park()
方法可以被另一个线程调用LockSupport.unpark()方法唤醒;Thread.sleep()
方法声明上抛出了InterruptedException
中断异常,所以调用者需要捕获这个异常或者再抛出;LockSupport.park()
方法不需要捕获中断异常;Thread.sleep()
本身就是一个native
方法;LockSupport.park()
底层是调用的Unsafe
的native
方法;
🌟Object.wait()和LockSupport.park()的区别
相同点:二者都会阻塞当前线程的运行
区别:
wait()
方法需要在synchronized
块中执行;park()
可以在任意地方执行;
- park ,unpark 以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
- park ,unpark 可以先 unpark,而 wait & notify 不能先 notify。
- wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU
wait()
方法声明抛出了中断异常,调用者需要捕获或者再抛出;park()
不需要捕获中断异常;
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
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用。
CountDownLatch一次可以唤醒几个任务? 多个
🌟CountDownLatch底层实现原理?
其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列: 同步队列sync queue 和条件队列condition queue,不同的条件会有不同的条件队列。CountDownLatch典型的用法是将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。
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
(ReentrantLock
也属于 AQS 同步器)和Condition
的。
CyclicBarrier
:循环屏障,用来进行线程协作,等待线程满足某个计数,才能触发自己执行
- 对于
CountDownLatch
,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。 - 对于
CyclicBarrier
,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。
🌟CyclicBarrier 的原理是什么?
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
🌟CountDownLatch 和 CyclicBarrier 有什么区别?
CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。
对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。
CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
CountDownLatch 应用场景:
1、某一线程在开始运行前等待 n 个线程执行完毕:启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
2、实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。
3、死锁检测:一个非常方便的使用场景是,你可以使用 n 个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
CyclicBarrier 应用场景:
CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如:我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。
❸Exchanger
🌟Exchanger主要解决什么问题?
Exchanger用于进行两个线程之间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
🌟对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:线程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
中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。
🌟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。
🌟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
类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
🌟CopyOnWriteArrayList有何缺陷,说说其应用场景?
- 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
- 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;
CopyOnWriteArrayList 适合读多写少的场景
❸ConcurrentLinkedQueue
Java 提供的线程安全的 Queue
可以分为阻塞队列和非阻塞队列
- 阻塞队列的典型例子是
BlockingQueue
,阻塞队列可以通过加锁来实现 - 非阻塞队列的典型例子是
ConcurrentLinkedQueue
, 非阻塞队列可以通过 CAS 操作实现。
ConcurrentLinkedQueue
这个队列使用链表作为其数据结构.ConcurrentLinkedQueue
应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
ConcurrentLinkedQueue
主要使用 CAS 非阻塞算法来实现线程安全。
ConcurrentLinkedQueue
适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue
来替代。
❹BlockingQueue
阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:
❺ConcurrentSkipListMap
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。
但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 。跳表的本质是同时维护了多个链表,并且链表是分层的。
📚参考资料
- ♥Java并发知识体系详解♥ | Java 全栈知识体系 (pdai.tech)
- 路人张的面试笔记 (mianshi.online)
- Java并发知识点总结 (github.com)
- 一文让你彻底明白ThreadLocal
- ThreadLocal(史上最全)
- CAS操作的底层原理以及应用详解
❤️Sponsor
您的支持是我不断前进的动力,如果您感觉本文对您有所帮助的话,可以考虑打赏一下本文,用以维持本博客的运营费用,拒绝白嫖,从你我做起!🥰🥰🥰
支付宝 | 微信 |
![]() |
![]() |