1)乐观锁 vs 悲观锁
悲观锁:预期锁冲突的概率很高,做的工作更多,付出的成本更多,更低效。
乐观锁:预期锁冲突的概率很低,做的工作更少,付出的成本更低,更高效。
举例:针对疫情
乐观态度:认为下一次疫情来了能够买到自己想要吃的食物,所以不必做特殊的准备。
悲观态度:认为下一次疫情来了不能买到自己想要吃的食物,这样就要做出准备,去超市购物,买一些自己喜欢的食物,然后把这些食物存放起来。(买食物增大开销,存食物占用空间)
2)读写锁 vs 普通的互斥锁
普通的互斥锁:只能有两个操作,加锁和解锁,只要来两个线程针对同一个对象加锁就会产生互斥。
读写锁:有三个操作,加写锁,加读锁,解如果代码加写锁,就只能进行写操作。如果代码加读锁,就只能进行读操作。
针对两个线程调用同一个读锁,不存在线程互斥关系的。
针对两个线程调用同一个写锁和一个线程调用读锁,一个线程调用写锁。存在线程互斥关系的。
3)重量级锁 vs 轻量级锁
重量级锁:做了更多的的事情,开销更大。
轻量级锁:做的事情更少,开销更小。
重量级锁和轻量级锁和上面的乐观锁和悲观锁有一定的重叠。也可以这么认为,通常情况下,悲观锁一般都是重量级锁,乐观锁一般都是轻量级锁,但是这个事情不绝对。
在使用锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的 mutex 接口),此时一般认为这是重量级锁。(操作系统的锁会在内核中做很多事情,比如让线程等待…)
如果锁是纯用户态实现的,此时一般认为这是轻量级锁。(用户态的代码更可控,也更高效)
4)挂起等待锁 vs 自旋锁(Spin Lock)
挂起等待锁:往往就是通过内核的一些机制来实现的,往往比较重。(重量级锁的一种典型实现)
自旋锁:往往就是通过用户态的代码来实现的,往往比较轻。
5)公平锁 vs 非公平锁
公平锁:多个线程在等待一把锁的时候,谁先来的,就是谁先获取到锁(遵循先来后到的原则)
非公平锁:多个线程在等待一把锁的时候,不遵循先来后到的原则(到来的线程不管谁是先来的谁是后到的,都是多个锁抢占式执行)。
6)可重入锁 vs 不可重入锁
可重入锁:一个线程,一把锁,连续加锁两次,不会出现死锁。
不可重入锁:一个线程,一把锁,连续加锁两次,会出现死锁。
CAS: 全称Compare and swap,字面意思:“比较并交换”,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
CAS 伪代码
下面写的代码不是原子的,真实的 CAS 是一个原子的硬件指令完成的。这个伪代码只是辅助理解CAS 的工作流程。
//address待比较的内存地址, expectValue预期内存中的值, swapValue希望把内存的值改成这个新的值。
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
上面的那一段交换逻辑,这就相当于硬件直接实现出来了,通过这一条指令,封装好,直接让我们用了。
CAS 最大的意义,就是让我们写的这种多线程安全的代码,提供了一个新的思路和方向。
1) 实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。这个操作是线程安全的。
代码演示:
import java.util.concurrent.atomic.AtomicInteger;
public class TestDome1 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread thread1 = new Thread(()->{
for(int i=0; i<50000; i++){
num.getAndIncrement();
}
});
Thread thread2 = new Thread(()->{
for(int i=0; i<50000; i++){
num.getAndIncrement();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(num);
}
}
上面代码不存在线程安全的问题。
基于CAS实现的++操作,这里面就可以保证既能够线程安全,又能够比 synchronize 高效。synchronize 会涉及到锁的竞争,两个线程要互相等待。
而CAS不涉及到线程阻塞等待。
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
//这里看起来是一个oldValue的变量,但是在实际上,这个可能是直接用一个寄存器来存储的。
//这个操作就相当于把数据从内存读到寄存器中(load)。
int oldValue = value;
//判定一下,当前内存的值是不是和刚才寄存器里取到的值一致,如果不一致返回false,否则返回true
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
在代码的第六行到第八行之间,按照我们之前的理解,这两个相邻的代码中读到的value的值是一样的。但是我们要考虑多线程的情况,有可能是其他的线程改变了value的值,我们再写多线程代码的时候,要时刻记得任意两个线程之间都可能执行一些其他的代码。
2)实现自旋锁
基于CAS能够实现 “自旋锁”
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;
}
}
当别的线程持有这个锁的时候,当前线程就会一直while循环,这个反复循环的过程就是 “忙等”。
我们认为这个自旋锁是轻量级锁,是一个乐观锁,我们有一个预期很快就能拿到这个锁(假设锁冲突不激烈),短暂的循环之后自己就能拿到锁。
CAS中的关键,是先比较,再交换,其中的比较的是当前值和旧值是不是相同的,把这两个值相同,就视为是中间没有发生过改变的。
但是上面存在一些问题,就是 当前值 和 旧值 相同,可能中间值没有改变过,也可能改变了,但是又变回来了,比如当前值是A,变为B,然后再变回A,这样中间值就像没有变过一样。
这样的漏洞在通常的情况下,没有什么影响,但是在极端情况下也会引起 bug。
举例:使用ATM取钱的例子。
有一位老铁,账户余额是100元,他去ATM上取钱,想要取50元。
当他按下取款操作的时候,机器卡了一下,这位老铁下意识的多按了一下,在正常的情况下使用CAS 只会有其中的一次生效。
但是在异常的情况下,有一位朋友在这期间给这位老铁转账了50,就会出现bug。
给要修改的值,引入版本号,在CAS比较数据当前值的同时,也要比较版本号是否符合预期。
1)CSA操作在读取旧值的同时,也要读取版本号。
2)真正修改的时候
结合上面的锁策略,我们就可以总结出,synchronized 具有以下特性(只考虑 JDK 1.8):
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。
1)锁粗化
一段逻辑中如果出现多次加锁解锁,编译器 + JVM 会自动进行锁的粗化。
加锁的粗细和加锁代码涉及的范围有关系,如果加锁代码涉及到的范围越大,就认为锁的越粗,如果加锁代码的范围小就是锁比较细。
锁粗细好坏。
如果锁比较粗,并发性就比较低,加锁的开销小。
如果锁比较细,并发性就比较高,加锁的开销大。
2)锁消除
编译器+JVM 判断锁是否可消除。如果可以,就直接消除。
由于使用 Runnable 不太适合让多线程返回一个结果,如果要返回结果就比较麻烦,由此就引入了Callable这个接口。
Callable 这个接口可以从多线程中带回一个值。
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000,使用 Callable 版本。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class TestDome2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//实现 Callable 接口中的 call 方法。其中的泛型为要返回的类型
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i=1; i<=100; i++){
sum += i;
}
return sum;
}
};
//把 callable 实例,用 FutureTask 封装一下。
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//创建线程
Thread thread = new Thread(futureTask);
thread.start();
//使用 futureTask.get() 等待 thread 线程结束,并且获取返回的结果,
int result = futureTask.get();
System.out.println(result);
}
}
可重入互斥锁。和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全。
ReentrantLock 的用法:
ReentrantLock 和 synchronize 的区别:
我们在日常开发中,绝大部分情况下,synchronize 就已经够用了。
这是一个更广义的锁。我们上面讲的锁就是一个 “二元信号量” 。
举例:大学生去图书馆
图书馆中有很多的房间,每一个房间有一定的座位,假设有一个count来记录这个房间的座位数,如果有人进入了这个房间座位就 - 1,如果有人出了这个房间座位就 + 1,这个count就表示信号量,当count 为零的时候就会进入阻塞等待。
代码演示
acquire()表示申请资源, release()表示释放资源。
import java.util.concurrent.Semaphore;
public class TestDome3 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4); //申请4个信号量(资源)
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("申请资源");
semaphore.acquire();
System.out.println("申请资源");
semaphore.acquire();
System.out.println("申请资源");
semaphore.acquire();
System.out.println("申请资源");
semaphore.acquire(); //阻塞等待线程
System.out.println("申请资源");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread thread = new Thread(runnable);
thread.start();
}
}
同时等待 N 个任务执行结束。
举例:跑步比赛
有 n 个选手,同时跑步,这个 n 个选手中有跑的快的,有跑的慢的。只有当最后一个人冲到终点,这个跑步比赛才算结束。
import java.util.concurrent.CountDownLatch;
public class TestDome4 {
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(10);
for(int i=0; i<10; i++){
Thread thread = new Thread(()->{
try {
Thread.sleep((long) (Math.random() * 10000));
System.out.println(Thread.currentThread().getName() + " 执行完毕");
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
}
}
集合类, 大部分都不是线程安全的
HashMap 本身线程不安全。
解决:使用 HashTable (不推荐),使用 ConcurrentHashMap (推荐)。
1)HashTable
HashTable 是通过给关键方法加锁,来确保线程安全的。
这个是针对 this 来加锁的,当有多个线程来访问这个 HashTable 的时候,无论是什么样的操作,无论是什么样的数据,都会出现锁竞争,这样的设计就会导致锁的竞争是非常大的,这个其中的效率就会比较低
2)ConcurrentHashMap
ConcurrentHashMap 是给哈希表中的每一个链表来进行加锁,来确保线程安全的,哈希表是一个数组,数组中的每一个元素是一个链表。
针对哈希表中两个不同链表的元素操作时,没有线程安全问题。
而且哈希表中的链表是很多的,但是链表的长度是很短的,这样锁竞争的概率就变小了。
3)ConcurrentHashMap 的优点
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 加锁
使用 AtomInteger 原子操作。
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) 进程和线程的区别?
进程是包含线程的,每个进程至少有一个线程存,即主线程。
进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。
进程是系统分配资源的最小单位,线程是系统调度的最小单位。