博客地址: Coding Lemon’s blog
所有文章会第一时间在博客更新!
这是大厂面试系列第二弹!
问题来自:八股文骚套路之Java并发(重构完善版)
通过打星与加粗的方式对下面面试题的重要性进行评级!难度是针对互联网大厂的。
- ⭐ :面试中不常问到,如果面试官问到尽量能答出来,答不出来也没关系。
- ⭐⭐ :面试中不常问到,但是如果面试官问到的话,答不出来对你的印象会减分。
- ⭐⭐⭐:面试中会问到,答不出来面试有点悬。面试官会惊讶为什么你这也不会。
- ⭐⭐⭐⭐:面试高频考点。
- ⭐⭐⭐⭐⭐:面试超高频考点。四星考点和五星考点是参加十场面试,至少能有五场面试问到这些的。大家在准备面试过程中尽量把这些知识点的回答条理梳理清楚,面试官一问就开背。
1.进程和线程的区别。(⭐⭐⭐⭐⭐)
进程:进程是程序的一次执行过程,是系统运行程序的基本单位。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
线程:线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
-
程序计数器为什么是私有的?
程序计数器用于记录当前线程执行的位置,所以程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。 -
虚拟机栈和本地方法栈为什么是私有的?
为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。 -
堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
2. 创建线程的方式(⭐⭐⭐⭐)
2.1 继承Thread类创建
通过继承Thread并且重写其run(),run方法中即线程执行任务。创建后的子类通过调用 start() 方法即可执行线程方法。
通过继承Thread实现的线程类,多个线程间无法共享线程类的实例变量。(需要创建不同Thread对象,自然不共享)
举例说明:
/**
* 通过继承Thread实现线程
*/
public class ThreadTest extends Thread{
private int i = 0 ;
@Override
public void run() {
for(;i<50;i++){
System.out.println(Thread.currentThread().getName() + " is running " + i );
}
}
public static void main(String[] args) {
for(int j=0;j<50;j++){
/**
* 两个线程都输出0~49的所有值,i的值没有共享
*/
if(j==20){
new ThreadTest().start() ;
new ThreadTest().start() ;
}
}
}
}
2.2 通过Runnable接口创建线程类
需要先定义一个类实现Runnable接口,并重写该接口的 run() 方法,此run方法是线程执行体。接着创建 Runnable实现类的对象,作为创建Thread对象的参数target,此Thread对象才是真正的线程对象。
通过实现Runnable接口的线程类,是互相共享资源的。
/**
* 通过实现Runnable接口实现的线程类
*/
public class RunnableTest implements Runnable {
//注意,这里如果不加volatile,线程1和线程2输出的值可能会重复,加了之后就不会重复了
private volatile int i;
@Override
public void run() {
for (; i < 50; i++) {
System.out.println(Thread.currentThread().getName() + " -- " + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
/**
* 两个线程随机输出0-49(值无重复)
*/
if (i == 20) {
RunnableTest runnableTest = new RunnableTest();
new Thread(runnableTest, "线程1").start();
new Thread(runnableTest, "线程2").start();
}
}
}
}
2.3 使用Callable和Future创建线程
从继承Thread类和实现Runnable接口可以看出,上述两种方法都不能有返回值,且不能声明抛出异常。而Callable接口则实现了此两点,Callable接口如同Runable接口的升级版,其提供的call()方法将作为线程的执行体,同时允许有返回值。
但是Callable对象不能直接作为Thread对象的target,因为Callable接口是 Java 5 新增的接口,不是Runnable接口的子接口。对于这个问题的解决方案,就引入 Future接口,此接口可以接受call() 的返回值,RunnableFuture接口是Future接口和Runnable接口的子接口,可以作为Thread对象的target 。并且, Future 接口提供了一个实现类:FutureTask 。
FutureTask实现了RunnableFuture接口,可以作为 Thread对象的target。
/**
* Callable后面跟的泛型就是返回值的类型
*/
public class CallableTest implements Callable<String>{
@Override
public String call() throws Exception {
return "这是一个返回值";
}
public static void main(String[] args){
CallableTest callableTest = new CallableTest();
try {
System.out.println(callableTest.call());
} catch (Exception e) {
e.printStackTrace();
}
}
}
总结:
- 继承用Thread,没有返回值,线程间不能共享数据
- 实现接口,Runnable没有返回值,Callable有返回值,返回值为设置的泛型T,线程间可以共享数据。
常见面试题:
- 一个线程有一个数组{“A”,“B”,“C”},另一个线程有数组{1,2,3},要求交替输出1A2B3C
/**
* @author zry
* @date 2021-10-25 19:13
*/
public class TestThread {
private int sign = 1;
private ReentrantLock lock = new ReentrantLock();
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
public void printNum() {
int[] arr = {1, 2, 3};
lock.lock();
try {
for (int i = 0; i < arr.length; i++) {
while (sign != 1) {
c1.await();
}
System.out.println(arr[i]);
sign = 2;
c2.signal();
}
} catch (Exception ignored) {
} finally {
lock.unlock();
}
}
public void printUnit() {
String[] arr = {"A", "B", "C"};
lock.lock();
try {
for (int i = 0; i < arr.length; i++) {
while (sign != 2) {
c2.await();
}
System.out.println(arr[i]);
sign = 1;
c1.signal();
}
} catch (Exception ignored) {
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
TestThread thread = new TestThread();
new Thread(() -> {
thread.printNum();
}).start();
Thread.sleep(1);
new Thread(() -> {
thread.printUnit();
}).start();
}
}
3. 什么是死锁,死锁如何产生,死锁如何避免(⭐⭐⭐⭐⭐)
死锁就是多个线程竞争并持有有限资源,且同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
产生死锁必须具备以下四个条件:
- 互斥条件: 该资源任意一个时刻只由一个线程占用。
- 请求与保持条件: 一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件: 若干进程之间形成一种头尾相接的循环等待资源关系。
预防死锁,只需要破坏死锁的产生的必要条件即可:
- 破坏请求与保持条件: 一次性申请所有的资源。
- 破坏不剥夺条件: 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件: 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。(其实就是预先计算一下资源分配是能够保证至少一个线程可以获取到需要的资源正常运行)
4. 并发编程的三大特性(⭐⭐⭐⭐)
-
有序性: as-if-serial和Happens-Before原则,即一个操作在另一个操作前面,不会重排序。使用volatile关键字保证多线程条件下不会进行指令重排序。
-
可见性: 保证共享变量的修改能够及时可见,synchronized 关键字,开始时会从内存中读取,结束时会将变化刷新到内存中,所以是可见的。volatile 关键值,通过添加lock指令,也是可见的。
-
原子性: 保证单一线程持有
5. synchronized 锁升级流程(⭐⭐⭐⭐⭐)
synchronized锁升级方向如下,并且锁升级过程是不可逆的。
- 无锁: 这个时候没有线程竞争,也没有加锁。
- 偏向锁(只有一个线程进入临界区): 线程访问对象头,比较对象头存储的线程ID,如果相同说明是同一个线程,再看对象头的Mark Word的偏向锁标识是否为1,如果是则加1,直接进入,如果不是则用CAS竞争锁(此时竞争成功会将偏向锁标识设置为1);如果线程ID不同,则锁升级为轻量级锁。
- 轻量级锁(多个线程交替进入临界区): 轻量级锁认为多个线程是交替进入临界区的,此时其实在时间片内竞争不存在,线程会先看能否修改Lock Record的指针指向自己,如果成功说明加锁成功;如果失败判断一下Lock Record的指针是否已经指向自己,如果是则重入锁,如果不是则继续升级锁。
如果这个对象是无锁的,JVM就会在当前线程的栈帧中建立一个叫 锁记录(Lock Record) 的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象。
-
自旋锁(包含在轻量级锁的操作过程中): 线程不断的请求获得锁,在等待设定次数后,再将锁升级。
-
重量级锁: 当线程自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。重量级锁会在用户态和内核态之间切换,代价很大。
6. volatile 关键字和 synchronized 的区别(⭐⭐⭐⭐⭐)
volatile的特点:
- 保证可见性
- 不保证原子性
- 禁止指令重排序
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写回了,他其他已经读取的线程的变量副本就会失效了,需要对数据进行操作,又要再次去主内存中读取了。
volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
之前我们说过当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,重点了解MESI(缓存一致性协议)。
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
如何发现数据是否失效呢?
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
总线风暴:
由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。
总结:volatile保证了可见性以及禁止指令重排序,但是不保证原子性。保证可见性是让多线程之间的共享数据遵循MESI(缓存一致性协议),有线程写数据时,会将其他CPU的该变量缓存行置为无效状态。其实是每个CPU通过不断嗅探总线上传播的数据来检查自己缓存的值是否过期,当发现内存地址被修改就会将其设置为无效状态,重新从系统内存中把数据读到处理器内存里。
volatile禁止指令重排序:
- 指令重排序: 为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
- as-if-serial: 不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
- 内存屏障: java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
- happens-before: 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
- volatile域规则: 必须等到该volatile域写操作完成,才能执行对该volatile域的读操作。
volatile与synchronized的区别:
-
volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
-
volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制,也就是都保证。
-
volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
-
volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
7. JMM(Java Memory Model,Java 内存模型)(⭐⭐⭐⭐⭐)
本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
8. ThreadLocal(⭐⭐⭐⭐)
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。也就是说这是ThreadLocal的值在每个线程中独享自己的副本。
ThreadLocal的内存泄露问题:
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法(ThreadLocalMap使用的是简单的线性探测法,如果发生了元素冲突,那么就使用下一个槽位存放)。
总结: 也就是说ThreadLocalMap 中的key因为是弱引用,在ThreadLocal没有被外部强引用的情况下,垃圾回收时,key会被回收掉,变为null,但value不会被回收,造成内存泄漏。 ThreadLocalMap调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。
什么是强引用?什么是弱引用?
- 强引用: 一直活着:类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。
- 回收就会死亡: 被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
为什么ThreadLocalMap使用弱引用?
两种情况:
- key 使用强引用: 引用ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
- key 使用弱引用: 引用ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set、get、remove的时候会被清除。
比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal被清理后key为null,对应的value在下一次ThreadLocalMap调用set、get、remove的时候可能会被清除。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
9. 线程池(⭐⭐⭐⭐⭐)
Java提供的线程池有哪几种?作用分别是什么?
-
newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。newFixedThreadPool固定线程池,使用完毕必须手动关闭线程池,否则会一直在内存中存在。
-
newCacheThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
-
newSIngleTheadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
-
newScheduledThewadPool:创建一个定长线程池,支持定时及周期性任务执行。
-
newWorkStealingPool(1.8新增):创建一个抢占式执行的线程池(任务执行顺序不确定),注意此方法只有在 JDK 1.8+ 版本中才能使用。
-
ThreadPoolExecutor:最原始的创建线程池的方式。
线程池有那些参数?
-
参数 1:corePoolSize
核心线程数,线程池中始终存活的线程数。 -
参数 2:maximumPoolSize
最大线程数,线程池中允许的最大线程数,当线程池的任务队列满了之后可以创建的最大线程数。(还有一个largestPoolSize: 是一个动态变量,是记录Poll曾经达到的最高值,也就是 largestPoolSize<= maximumPoolSize。)
3.参数 3:keepAliveTime
最大线程数可以存活的时间,当线程中没有任务执行时,最大线程就会销毁一部分,最终保持核心线程数量的线程。
4.参数 4:unit:
单位是和参数 3 存活时间配合使用的,合在一起用于设定线程的存活时间 ,参数 keepAliveTime 的时间单位有以下 7 种可选:
1. TimeUnit.DAYS:天
2. TimeUnit.HOURS:小时
3. TimeUnit.MINUTES:分
4. TimeUnit.SECONDS:秒
5. TimeUnit.MILLISECONDS:毫秒
6. TimeUnit.MICROSECONDS:微妙
7. TimeUnit.NANOSECONDS:纳秒
5.参数 5:workQueue
一个阻塞队列,用来存储线程池等待执行的任务,均为线程安全,它包含以下 7 种类型:
1. ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
2. LinkedBlockingQueue:一个由链表结构组成的无界阻塞队列。
3. SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
4. PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
5. DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
6. LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
7. LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
较常用的是 LinkedBlockingQueue 和 Synchronous,线程池的排队策略与 BlockingQueue 有关。
6.参数 6:threadFactory
线程工厂,主要用来创建线程,默认为正常优先级、非守护线程。
7.参数 7:handler(常问)
拒绝策略,拒绝处理任务时的策略,系统提供了 4 种可选:
1. AbortPolicy:拒绝并抛出异常。
2. CallerRunsPolicy:使用当前调用的线程来执行此任务(重试)。
3. DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
4. DiscardPolicy:忽略并抛弃当前任务。
默认策略为 AbortPolicy。
ThreadPoolExecutor 关键节点的执行流程如下:
- 当线程数小于核心线程数时,创建线程。
- 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。
- 当线程数大于等于核心线程数,且任务队列已满:若线程数小于最大线程数,创建线程;若线程数等于最大线程数,抛出异常,拒绝任务。
该如何选择线程池?
我们来看下阿里巴巴《Java开发手册》给我们的答案:
【强制要求】 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
- CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
所以综上情况所述,我们推荐使用 ThreadPoolExecutor 的方式进行线程池的创建,因为这种创建方式更可控,并且更加明确了线程池的运行规则,可以规避一些未知的风险。
说人话:我们在使用线程池时不要使用Java已有的线程池模型(因为有这样或那样的缺陷),而是通过自定义线程池参数,创建自定义的线程池。
实际使用线程池的地方:商品详情页、批处理等等。
线程池的执行过程:
核心线程->队列->最大线程->拒绝策略
10. AQS(⭐⭐⭐⭐⭐)
AQS(AbstractQueuedSynchronizer),所谓的AQS即是抽象的队列式的同步器,内部定义了很多锁相关的方法,我们熟知的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等都是基于AQS来实现的。
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
AQS 使用一个 int 成员变量(图中的state)来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作。
AQS 定义两种资源共享方式
Exclusive(独占): 只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享): 多个线程可同时执行,如 CountDownLatch、Semaphore、 CyclicBarrier、ReadWriteLock。
总结:AQS是一个抽象的队列式的同步器,内部维护了一个虚拟的双向队列,以及一个int类型变量state,如果请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果请求资源被占用,则添加到CLH队列尾部。如果资源被解锁,则state设置为0,然后根据不同的类型进行锁抢占(公平、非公平),抢占资源完毕后将state设置为1,其他线程继续在CLH队列等待。
11. 乐观锁和悲观锁的区别(⭐⭐⭐⭐⭐)
悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用CAS来控制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。
总结:悲观锁读取数据先上锁;乐观锁用CAS,如果CAS成功就不上锁。
12. CAS 了解么?原理?什么是 ABA 问题?ABA 问题怎么解决?(⭐⭐⭐⭐⭐)
CAS是compare and swap的简称,从字面上理解就是比较并更新,简单来说:从某一内存上取值V,和预期值A进行比较,如果内存值V和预期值A的结果相等,那么我们就把新值B更新到内存,如果不相等,那么就重复上述操作直到成功为止。
CAS如何解决ABA问题?
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
**解决方法:**ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。或者增加时间戳(原理与增加版本号类似)
自旋CAS如何解决循环时间长开销大问题?
一般自旋CAS会有次数限制,超过既定次数会升级锁。
CAS只能保证一个共享变量的原子操作吗?
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。