在多线程初阶我们主要是讲解多线程的使用和案例,接下来我们讲的主要是关于在面试中多线程的考点.
一.常见的锁策略
1.乐观锁VS悲观锁
简单来说,乐观锁和悲观锁的区别就是预测锁冲突的概率的不同.
乐观锁,顾名思义,比较乐观,预测锁冲突的概率低,也就是说每次拿锁的时候大概率是可以拿到的.
悲观锁,顾名思义,比较悲观,预测锁冲突的概率高,也就是说每次拿锁的时候大概率拿不到锁.
对于synchronized来说,synchronized并不是单一的乐观锁或者悲观锁,
他是会在乐观锁和悲观锁之间转换,初始的时候使用乐观锁策略,当发现锁冲突概率较高的时候,使用悲观锁策略
2.重量级锁VS轻量级锁
重量级锁,可以简单理解为加锁的开销大,等待锁的线程多,等待时间比较长
轻量级锁,加锁的开销比较小,等待锁的线程比较少,等待时间比较短.
synchronized是一个轻量级锁,如果锁冲突较多,会转换为重量级锁
3.自旋锁VS挂起等待锁
挂起等待锁可以说是重量级锁和悲观锁的典型实现.
什么是挂起等待锁?
挂起等待锁当遇到锁冲突的时候,就会让线程调度出CPU,直到被其他线程唤醒.
这个机制就很悲观,他预测锁很难拿到,所以不会像接下来的自旋锁一样一直尝试去拿锁,同时他又会调度出CPU一直等待被唤醒.
自旋锁是轻量级锁和乐观锁的典型实现
自旋锁当遇到锁冲突的时候,自旋锁如果第一次没有拿到锁,CPU并不会将这个线程调度走,而是会一直尝试拿锁,直到拿到这个锁.
因为大部分情况下,虽然第一次没有拿到锁,但是锁很快就会释放,所以没必要去调度出CPU,只需要一直在CPU上等待锁释放然后一直尝试去拿锁就行.
自旋锁的优点:没有放弃CPU,不涉及线程阻塞和等待,一但锁释放,很大概率可以第一时间拿到锁.
自旋锁的缺点:一但其他线程占有锁的时间比较长,就会持续消耗CPU资源(挂起等待锁是不会消耗CPU资源的).
synchronized的轻量级锁策略大概率是通过自旋锁实现的
synchronized是采取自适应的形式,当锁冲突低的时候,采取自旋锁,锁冲突高的时候采取挂起等待锁策略
4.公平锁VS非公平锁
公平锁:遵循"先来后到"原则,线程A占着锁,线程B和C在等待锁释放,线程B比线程C先来的,等到线程A释放锁之后,会根据"先来后到",B会先拿到锁
非公平锁:不遵循"先来后到"原则,B和C都有可能拿到锁.
操作系统内部的线程调度是随机的,如果不做额外限制,锁就是非公平锁.
如果想要实现公平锁,就需要引入额外的数据结构来记录线程的先后顺序.
synchronized是非公平锁
5.可重入锁VS不可重入锁
可重入锁:字面意思,可以重新进入的锁
举个简单的例子,一个线程里面有一个递归函数,递归函数里面有加锁,每一次递归都会对同一把锁进行加锁操作,会不会阻塞呢?
对于可重入锁来说,允许同一个线程对同一把锁进行多次加锁操作(因此,可重入锁也叫递归锁)
对于不可重入锁来说,则是不允许.
Java里只要以Reentrant开头的所有锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入锁.
而Linux系统提供的mutex是不可重入锁.
6.读写锁
多线程之间,不同线程对同一个变量进行读取是不会产生线程安全问题的.
只有当多个线程对同一变量进行修改,或者不同线程对同一变量有修改和读取操作的时候才会产生线程安全问题.
但是如果在这两个场景中都是用同一个锁,就会产生极大的资源消耗.因此读写锁诞生了
读写锁分为读锁和写锁,读锁与读锁之间不会互斥,只有读锁和写锁或者写锁与写锁之间才会产生互斥.
读写锁就是把读操作和写操作分开对待,在Java标准库中提供了ReentrantReadWriteLock类,实现读写锁
ReentrantReadWriteLock.ReadLock表示读锁,提供lock/unlock两种方法
ReentrantReadWriteLock.WriteLock表示写锁,提供lock/unlock两种方法
lock和unlock分别是用来加锁和解锁
下面是使用的示例代码:
java
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.Lock;
public class SharedDocument {
// 1. 创建一把读写锁
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 2. 从里面拿出两把"分钥匙"
private final Lock readLock = rwLock.readLock(); // 读锁
private final Lock writeLock = rwLock.writeLock(); // 写锁
private String content = "初始内容"; // 共享数据
// --- 读书人的方法(很多人可以同时进)---
public void read() {
readLock.lock(); // 加读锁
try {
System.out.println(Thread.currentThread().getName() + " 正在阅读...");
try { Thread.sleep(1000); } catch (InterruptedException e) {} // 模拟读花了1秒
System.out.println(Thread.currentThread().getName() + " 读完了: " + content);
} finally {
readLock.unlock(); // 千万别忘了解锁
}
}
// --- 写书人的方法(一次只能进一个,且不仅读的人不能进,其他写的人也不能进)---
public void write(String newContent) {
writeLock.lock(); // 加写锁
try {
System.out.println(">>> " + Thread.currentThread().getName() + " 正在修改数据...");
try { Thread.sleep(2000); } catch (InterruptedException e) {} // 模拟写花了2秒
this.content = newContent;
System.out.println(">>> " + Thread.currentThread().getName() + " 修改完毕!");
} finally {
writeLock.unlock();
}
}
}
synchronized不是读写锁!!!
7.相关面试题
1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
答:悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁。
乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突。
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex),获取到锁再操作数据,获取不到锁就等待。乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。
2.介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁。
读锁和读锁之间不互斥。写锁和写锁之间互斥。写锁和读锁之间互斥。
读写锁最主要用在 "频繁读,不频繁写" 的场景中。
3.什么是自旋锁?为什么要使用自旋锁策略呢?缺点是什么?
如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。
第一次获取锁失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁。
相比于挂起等待锁,优点:没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。
缺点:如果锁的持有时间较长,就会浪费 CPU 资源。
4.synchronized是可重入锁吗?
是可重入锁。
可重入锁指的就是连续两次加锁不会导致死锁。
实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。
二.CAS
CAS:Compare and swap,字面意思"比较和交换"
假设内存中的原数据是V,旧的预期值是A,需要修改新值B
一个CAS涉及的操作:
1.比较A和V是否相等
2.如果相等将B写入V
3.返回是否成功
听起来这些功能好像都很简单,不就是对比和交换嘛,
但是CAS它是原子的,和之前我们学的那些对比交换,我们可以知道在多线程环境下是线程不安全的.
但是CAS操作天然是原子性操作.
CAS是怎么实现的:
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。
简而言之,是因为硬件予以了支持,软件层面才能做到。
CAS的应用:
1.实现原子类
标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式实现的
典型的就是AtomicInteger,其中getAndIncrement相当于i++
这个操作还是线程安全的

可以看到即使没有加锁,也是能够正确算出结果
2.实现自旋锁
自旋锁伪代码:
java
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有,那么就自旋等待.
// 如果这个锁没有被别的线程持有,那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
3.CAS产生的ABA问题
什么是ABA问题?
假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A。
接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要:
先读取 num 的值,记录到 oldNum 变量中。
使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。
但是,在 t1 执行这两个操作之间,t2 线程可能把 num 的值从 A 改成了 B,又从 B 改成了 A。
线程 t1 的 CAS 是期望 num 不变就修改,但 num 的值已经被 t2 给改了,只不过又改成 A 了。这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程。

所以这种情况有时候就会产生bug,那么该怎么解决呢?
其实很简单,引入版本号即可.
CAS在修改数据的时候不仅要看数据和旧值的同时,还要看版本号是否符合预期
CAS每次进行修改的时候版本号都进行+1操作
真正修改的时候,如果当前版本号高于读取到的版本号,就说明数据已经修改过了
4.相关面试题
1. 讲解下你自己理解的 CAS 机制
全称 Compare and swap,即 "比较并交换"。
相当于通过一个原子的操作,同时完成 "读取内存,比较是否相等,修改内存" 这三个步骤。
本质上需要 CPU 指令的支撑。
2. ABA 问题怎么解决?
给要修改的数据引入版本号。
在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增;如果发现当前版本号比之前读到的版本号大,就认为操作失败。
三.synchronized原理
结合上面的锁策略,我们就可以总结出,synchronized 具有以下特性(只考虑 JDK 1.8):
1.开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
2.开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
3.实现轻量级锁的时候大概率用到的自旋锁策略。
4.是一种不公平锁。
5.是一种可重入锁。
6.不是读写锁。
1.synchronized的工作原理:
JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升
级。

(1).偏向锁:
第一个尝试加锁的线程,会由无锁状态转向偏向锁状态.
偏向锁并不是真正的加锁,只是对象头做了一个偏向锁标记,记录这个锁属于哪个线程
如果后续没有其他线程来竞争这个锁的话,就不需要进行其他操作了(减少加锁的开销)
如果后续有其他线程竞争这个锁,由于之前已经在锁对象中记录了这个锁属于哪个线程,然后判断当前竞争锁的线程和之前的线程是否一致,不一致就把原来的线程的偏向锁转向轻量级锁状态
偏向锁本质上相当于"延迟加锁",能不加锁就不加锁,减少不必要的开销
(2).轻量级锁
随着其他线程的竞争锁,由偏向锁状态转向轻量级锁状态(自适应自旋锁)
此处的自旋锁是通过CAS实现的
通过CAS检查并更新⼀块内存(⽐如null=>该线程引用)
如果更新成功,则认为加锁成功
如果更新失败,则认为锁被占⽤,继续⾃旋式的等待(并不放弃CPU).
由于自旋锁会长时间占有CPU资源
如果长时间得不到锁(到达一定时间或者一定自旋次数),就不再自旋了,这就是自适应
(3).重量级锁
如果竞争进一步加剧,自旋不能快速得到锁,就会由轻量级锁转向重量级锁(挂起等待锁)
2.其他的优化操作
(1)锁消除
JVM+编译器判断当前锁是否可以消除,如果可以就直接消除
举个例子,某一个synchronized代码块只在单线程环境下用到了,里面的锁是没必要加的
这时候加锁操作反而会消耗更多的资源
这时候就会进行锁消除
(2)锁粗化
一段逻辑中,如果多次出现加锁解锁的操作,JVM+编译器就会自动进行锁粗化
看下图:由上面到下面就是锁粗化

3.相关面试题
(1)什么是偏向锁
偏向锁不是真的加锁,只是在锁的对象头中记录⼀个标记(记录该锁所属的线程).
如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销.
⼀旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态.
(2)synchronized的工作原理
在上面提到了,大家可以详细看上面讲解
四.JUC的常见类
JUC全称:java.util.concurrent
1.Callable接口
Callable是一个接口,相当于是把线程封装成了一个返回值,方便程序员借助多线程的方式计算结果
类似于Runnable,都是用来给线程执行任务的
Runnable有run()方法,返回值是void
Callable有call()方法,返回值自定义
举个用线程计算1+2+3+...+1000的例子
java
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo37 {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
int result = task.get();
System.out.println(result);
}
}
Callable的用法:
1.先创建一个Callable对象,然后重写call()方法
2.用FutureTask接收Callable对象
3.把FutureTask对象传给线程
4.通过FutureTask中的get方法得到最后的结果
为什么这里不能直接把Callable对象传给Thread呢?
因为Thread只能接受Runnable对象,FutureTask就像是一个转换器,把Callable转为Runnable然后传给Thread.
2.ReentrantLock
可重入互斥锁,与synchronized类似,但是需要手动加锁解锁
lock(): 加锁,如果获取不到锁就死等.
trylock(超时时间):加锁,如果获取不到锁,等待⼀定的时间之后就放弃加锁.
unlock(): 解锁
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock和synchronized的区别:
1.synchronized 是一个关键字,是 JVM 内部实现的(大概率是基于 C++ 实现)。ReentrantLock 是标准库的一个类,在 JVM 外实现的(基于 Java 实现)。
2.synchronized 使用时不需要手动释放锁。ReentrantLock 使用时需要手动释放,使用起来更灵活,但是也容易遗漏 unlock。
3.synchronized 在申请锁失败时,会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
4.synchronized 是非公平锁,ReentrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式。
5.更强大的唤醒机制:synchronized 是通过 Object 的 wait/notify 实现等待 - 唤醒,每次唤醒的是一个随机等待的线程。ReentrantLock 搭配 Condition 类实现等待 - 唤醒,可以更精确控制唤醒某个指定的线程。

如何选择使用哪个锁:
1.锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便。
2.锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更灵活控制加锁的行为,而不是死等。
3.如果需要使用公平锁,使用 ReentrantLock。
3.原子类
在CAS那部分已经有过代码演示,可以往上翻找

4.线程池
这一部分在上一篇文章有具体讲解,这里只略微提及,具体可以看上一篇文章
虽然创建销毁线程比创建销毁进程更轻量,但是在频繁创建销毁线程的时候还是会比较低效.
线程池就是为了解决这个问题.如果某个线程不再使用了,并不是真正把线程释放,而是放到⼀个"池
子"中,下次如果需要用到线程就直接从池子中取,不必通过系统来创建了.

5.信号量Semaphore
用来表示可用资源的个数,本质上是一个计数器
acquire方法表示获得资源
release方法表示释放资源
创建10个线程,去获取资源,看看是什么效果
java
package thread;
import java.util.concurrent.Semaphore;
public class Demo38 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);
Runnable task = new Runnable(){
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("线程 " + Thread.currentThread().getName() + " 获得了信号量");
Thread.sleep(1000);
System.out.println("线程 " + Thread.currentThread().getName() + " 释放了信号量");
semaphore.release();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
};
for(int i = 0;i < 10;i++){
Thread t = new Thread(task);
t.start();
}
}
}
效果图:
x
6.CountDownLatch
同时等待N个任务执行结束
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
构造CountDownLatch实例,初始化10表示有10个任务需要完成.
每个任务执行完毕,都调用latch.countDown()
在CountDownLatch内部的计数器同时自减
主线程中使用latch.await()阻塞等待所有任务执行完毕.相当于计数器为0了.

设置十个线程,每个线程随机休眠,直到所有线程休眠结束
java
package thread;
import java.util.concurrent.CountDownLatch;
public class Demo39 {
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(10);
Runnable task = new Runnable(){
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " 开始执行");
Thread.sleep((long) (Math.random() * 1000));
System.out.println(Thread.currentThread().getName() + " 执行完毕");
latch.countDown();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
};
for(int i = 0;i < 10;i++){
Thread t = new Thread(task);
t.start();
}
try {
latch.await();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("所有任务完成");
}
}
结果:

7.相关面试题
1. 线程同步的方式有哪些?
synchronized、ReentrantLock、Semaphore 等都可以用于线程同步。
2. 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例:
synchronized 使用时不需要手动释放锁。ReentrantLock 使用时需要手动释放,使用起来更灵活。
synchronized 在申请锁失败时,会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
synchronized 是非公平锁,ReentrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式。
synchronized 是通过 Object 的 wait/notify 实现等待 - 唤醒,每次唤醒的是一个随机等待的线程。
ReentrantLock 搭配 Condition 类实现等待 - 唤醒,可以更精确控制唤醒某个指定的线程。
3. AtomicInteger 的实现原理是什么?
基于 CAS 机制。伪代码如下:
java
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
4. 信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示 "可用资源的个数",本质上就是一个计数器。
使用信号量可以实现 "共享锁",比如某个资源允许 3 个线程同时使用,那么就可以使用 P 操作作为加锁,V 操作作为解锁,前三个线程的 P 操作都能顺利返回,后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作。
5. 解释一下 ThreadPoolExecutor 构造方法的参数的含义
在上一篇文章中有详细讲解
五.线程安全的集合类
原来的集合类,大部分都不是线程安全的。
Vector, Stack, HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的。
1.多线程环境使用 ArrayList
1.自己使用同步机制 (synchronized 或者 ReentrantLock)
2.Collections.synchronizedList(new ArrayList)
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
synchronizedList 的关键操作上都带有 synchronized
3.使用 CopyOnWriteArrayList
CopyOnWrite 容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素。
添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
优点:在读多写少的场景下,性能很高,不需要加锁竞争。
缺点:占用内存较多。新写的数据不能被第一时间读取到。
2.多线程环境使用队列
1.ArrayBlockingQueue基于数组实现的阻塞队列
2.LinkedBlockingQueue基于链表实现的阻塞队列
3.PriorityBlockingQueue基于堆实现的带优先级的阻塞队列
4.TransferQueue最多只包含一个元素的阻塞队列
3.多线程环境使用哈希表
HashMap 本身不是线程安全的。
在多线程环境下使用哈希表可以使用:Hashtable,ConcurrentHashMap
(1) Hashtable
只是简单的把关键方法加上了 synchronized 关键字。
这相当于直接针对 Hashtable 对象本身加锁。
如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
size 属性也是通过 synchronized 来控制同步,也是比较慢的。
一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低。
(2) ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化。以 Java 1.8 为例:
读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁。
加锁的方式仍然是使用 synchronized,但不是锁整个对象,而是 "锁桶"(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率。
充分利用 CAS 特性。比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
优化了扩容方式:化整为零
发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去。
扩容期间,新老数组同时存在。
后续每个来操作 ConcurrentHashMap 的线程,都会参与搬家的过程。每个操作负责搬运一小部分元素。
搬完最后一个元素再把老数组删掉。
这个期间,插入只往新数组加。
这个期间,查找需要同时查新数组和老数组。
ConcurrentHashMap每个哈希桶都有一个锁,只有当两个线程同时访问同一个哈希桶上的数据才会有锁冲突
4.相关面试题
1. ConcurrentHashMap 的读是否要加锁,为什么?
读操作没有加锁。
目的是为了进一步降低锁冲突的概率。为了保证读到刚修改的数据,搭配了 volatile 关键字。
2. 介绍下 ConcurrentHashMap 的锁分段技术?
这个是 Java 1.7 中采取的技术,Java 1.8 中已经不再使用了。
简单的说就是把若干个哈希桶分成一个 "段"(Segment),针对每个段分别加锁。
目的也是为了降低锁竞争的概率。当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争。
3. ConcurrentHashMap 在 jdk1.8 做了哪些优化?
取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
将原来数组 + 链表的实现方式改进成数组 + 链表 / 红黑树的方式。
当链表较长的时候(大于等于 8 个元素)就转换成红黑树。
4. Hashtable 和 HashMap、ConcurrentHashMap 之间的区别?
HashMap:线程不安全。key 允许为 null
Hashtable:线程安全。使用 synchronized 锁 Hashtable 对象,效率较低。key 不允许为 null。
ConcurrentHashMap:线程安全。使用 synchronized 锁每个链表头结点,锁冲突概率低,充分利用 CAS 机制。优化了扩容方式。key 不允许为 null
六.其他面试题
1. 谈谈 volatile 关键字的用法?
volatile 能够保证内存可见性,强制从主内存中读取数据。
此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值。
2. Java 多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区,堆区,栈区,程序计数器。
其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
3. Java 创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
通过 Executors 工厂类创建。创建方式比较简单,但是定制能力有限。
通过 ThreadPoolExecutor 创建。创建方式比较复杂,但是定制能力强。
LinkedBlockingQueue 表示线程池的任务队列。用户通过 submit/execute 向这个任务队列中添加任务,再由线程池中的工作线程来执行任务。
4. Java 线程共有几种状态?状态之间怎么切换的?
NEW:安排了工作,还未开始行动。新创建的线程,还没有调用 start 方法时处在这个状态。
RUNNABLE:可工作的。又可以分成正在工作中和即将开始工作。调用 start 方法之后,并正在
CPU 上运行 / 在即将准备运行的状态。
BLOCKED:使用 synchronized 的时候,如果锁被其他线程占用,就会阻塞等待,从而进入该状态。
WAITING:调用 wait 方法会进入该状态。
TIMED_WAITING:调用 sleep 方法或者 wait(超时时间) 会进入该状态。
TERMINATED:工作完成了。当线程 run 方法执行完毕后,会处于这个状态。
5. 在多线程下,如果对一个数进行叠加,该怎么做?
使用 synchronized / ReentrantLock 加锁
使用 AtomicInteger 原子操作。
6. Servlet 是否是线程安全的?
Servlet 本身是工作在多线程环境。
如果在 Servlet 中创建了某个成员变量,此时如果有多个请求到达服务器,服务器就会多线程进行操作,是可能出现线程不安全的情况的。
7. Thread 和 Runnable 的区别和联系?
Thread 类描述了一个线程。Runnable 描述了一个任务。
在创建线程的时候需要指定线程完成的任务,可以直接重写 Thread 的 run 方法,也可以使用 Runnable 来描述这个任务。
8. 多次 start 一个线程会怎么样
第一次调用 start 可以成功调用。
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常。
9. 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上,相当于针对当前对象加锁。
如果这两个方法属于同一个实例:线程 1 能够获取到锁,并执行方法。线程 2 会阻塞等待,直到线程 1 执行完毕,释放锁,线程 2 获取到锁之后才能执行方法内容。
如果这两个方法属于不同实例:两者能并发执行,互不干扰。
10. 进程和线程的区别?
进程是包含线程的。每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间。同一个进程的线程之间共享同一个内存空间。
进程是系统分配资源的最小单位,线程是系统调度的最小单位。