目录
4.1 HashTable 和 ConcurrentHashMap的区别
一. 常见的锁策略
1.1 乐观锁和悲观锁
说到乐观和悲观这两个概念,大家都不陌生,生活中我们也要常常面对一些乐观和悲观的时候,但是这是站在自身的角度去看待的,有的人看待一件事他认为是乐观的,而有的人认为他是悲观的;这里的 "乐观" 和 "悲观" 和我们说的乐观锁和悲观锁也是很相似的;
乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据;
举个例子:假设有A 和 B 两个线程,他们要去获取数据,因为他们是乐观的,所以双方不会认为他们去修改数据,所以他们就拿到数据后执行各自的事情去了,还有一个特点就是线程A和B在更新共享数据之前,他们要去判断这个共享数据是否被其他线程修改,如果没有修改的话,那么就直接更新内存中共享变量的值,那如果被修改了,就会报错或者去执行其他相关的操作了
悲观锁:每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁;
举个例子:还是有A 和 B两个线程,A 和 B要去拿数据,因为它是悲观的,所以在拿数据时需要进行加锁,假设A拿到了锁,那么B就会进入阻塞等待的状态,知道A释放锁,CPU会唤醒等待的线程B,B才能拿到这个锁,从而对数据进行操作;
总体来说,悲观锁一般要做的工作多一点,效率会更低一些;而乐观锁要做的事少一点,效率更高一点;
1.2 轻量级锁和重量级锁
轻量级锁:加锁和解锁的过程中更快更高效;
重量级锁:加锁和解锁的过程中更慢更低效;
这里看来,轻量级,重量级虽然和乐观,悲观不是一回事,但是有那么一定的相似,可以认为一个乐观锁可能是一个轻量级锁,但不是绝对的;关于这块后面还是会细说;
1.3 自旋锁和挂起等待锁
自旋锁是轻量级锁的一种代表实现,当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为
自旋锁(spinlock);
优点:自旋锁一旦被释放,就能第一时间拿到锁,自旋锁是纯用户态操作,所以速度很快;
缺点:要一直等待,会消耗CPU资源
挂起等待锁是重量级锁的一直代表实现,当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁;
优点:不需要盲等,在等待的过程中可以参与别的事情,充分利用了CPU的资源;
缺点:如果锁被释放,不能第一时间拿到锁,挂起等待锁是通过内核的机制来实现,所以时间会更长,效率会更低;
1.4 互斥锁和读写锁
互斥锁:互斥锁是一个非常霸道的存在,比如有线程A,B,当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞,
我们学过的Synchronized就是互斥锁;
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:
注:互斥锁和自旋锁最大的区别:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
读写锁:它由 读锁 和 写锁 两部分构成,如果只读取共享资源用 读锁 加锁,如果要修改共享资源则用 写锁 加锁。
读写锁一般有 3 种情况:
1.给读加锁
2.给写加锁
3.解锁
读写锁中的约定:
- 读锁和读锁之间,不会有锁竞争,不会产生阻塞等待
- 写锁和写锁之间,有锁竞争
- 读锁和写锁之间,有锁竞争
1.5 可重入锁和不可重入锁
针对一个线程,针对一把锁,连续加锁两次,如果出现死锁了,那就是不可重入锁,如果不死锁,那就是可重入锁;
Object locker = new Object(); synchronized(locker){ synchronized(locker){ } }
像上述这样的代码就是加锁两次的情况,第二次加锁需要等待第一个锁释放,第一个锁释放,需要等待第二个锁加锁成功,所以这种情况就矛盾了,但是并不会真正的死锁,因为synchronized是可重入锁,加锁的时候会先判定一下,看当前尝试申请锁的线程是不是已经拥有锁了,如果是的话,就不会矛盾;
synchronized 和 ReentrantLock 都是可重入锁,可重入锁最大的意思就是为了防止死锁;
1.6 公平锁和非公平锁
首先从字面意思理解,先到的线程会优先获取资源,后到的会进行排队等待,这种是公平的,而非公平锁是不遵循这个原则的,其实也很好理解,看下图:
这种情况就是公平的,遵循先到先得的规矩;
而像这种情况,就是非公平的,存在 "插队" 的现象;
1.7 关于锁策略的相关面试题
1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁;
乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突;
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待;
2. 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁;
读锁和读锁之间不互斥;
写锁和写锁之间互斥;
写锁和读锁之间互斥;
读写锁最主要用在 "频繁读,不频繁写" 的场景中;
3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
4. synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁5. synchronized的特点:
- 既是乐观锁,也是悲观锁
- 既是轻量级锁,也是重量级锁
- 轻量级锁是基于自选锁实现的,重量级锁是基于挂起等待锁实现的
- 不是读写锁
- 是可重入锁
- 是非公平锁
二. CAS
1. 概念
全称Compare and swap,比较 内存A 和 寄存器R 中的数据值,如果数值相同,就把 内存B和 寄存器R 的数值进行交换;
2. 特点
CAS最大的特点就是一个CAS操作是单条CPU指令,它是原子的,所以既是CAS不加锁,也能保证线程的安全;
在JDK1.8中针对于CAS提供了一个类:
CAS中的ABA问题:
ABA问题是CAS中的面试的高频问题,我们都知道,CAS是对比内存和寄存器的值,看看是否相同,就是通过这个对比,来检测内存是不是改变过,要么相同,要么不同,不同都好区别,但是有一种相同不是真正意义上的相同,而是不确定这个值中间是否发生过改变,改变了原来的东西,但是变回到原来的状态,假设原来是a,然后变成b,后来又变成a,这就是 a->b->a 问题了;
如何解决ABA问题呢???
ABA问题实质上就是一个反复横跳的问题,我们只要约定数据只能单方面变化,要么数据只能增加,要么数据只能减小,那么问题就迎刃而解了;
如果我们要求数据既能增大又能减小,我们可以约定一个版本号变量,约定版本号只能增加,并且每次修改,都会增加一个版本号,这样我们每次对比的时候就是拿版本号去对比,而不是对比数值本身,这样也能很好的解决问题了;
三. Synchronized 原理
3.1 基本特点
- 1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 3. 实现轻量级锁的时候大概率用到的自旋锁策略
- 4. 是一种不公平锁
- 5. 是一种可重入锁
- 6. 不是读写锁
3.2 加锁步骤
过程:
- 最开始是没有锁的;
- 刚开始加锁,是一个偏向锁状态;
- 遇到锁竞争,就转化为轻量级锁状态(自旋锁);
- 竞争激烈时,就会变成重量级锁,这一过程交给内核阻塞等待;
关于偏向锁:
偏向锁并非是真的加锁,只是简单的标记一下,想占有这把锁,如果没有锁竞争,那就不加锁,如果有别的线程来竞争这把锁,那么就升级成轻量级锁,这样做既保证了效率,又保证了线程安全;
3.3 锁消除
锁消除是编译阶段做的一种优化手段,用来检测当前代码是否多线程任务 或者 是否有必要加锁,如果没有必要,又把锁给写了,就会在编译过程中自动把锁去掉;
假如在不涉及线程安全的问题时,我们用 synchronized 关键字对一个操作进行加锁了,那么在编译阶段,就会自动把锁进行一个消除,锁消除这个机制是一个智能化的操作,它会根据不同的代码,去判断当前的操作需不需要进行加锁,如果不需要,就会自动消除;
3.4 锁粗化
提到锁的粗化,就要先提到一个概念叫锁的粒度,锁的粒度就是 synchronized 代码块里包含代码的多少,一般认为代码越多,粒度越粗,代码越少,粒度越细;
通常写代码的情况下,我们是希望锁的粒度更小一点,因为这样串行的代码少,并发执行的代码就越多;
举个例子:
假设我们给领导打电话汇报3份工作,分两种情况:
1. 先打个电话,汇报 A 的进展,再挂电话
再打个电话,汇报 B 的进展,再挂电话
再打个电话,汇报 C 的进展,再挂电话
每次领导接电话就是一个加锁的过程,别人(其他线程)想要给领导打电话就是处于一个阻塞等待的状态,挂电话就是释放锁;当你挂断电话后,再想去汇报工作B的进展,你不一定能打进去,领导可能和别人正在通话,这样一来,你再想打进去就要阻塞等待一会,这个过程就相当于把锁的粒度拆分的更细了,但是每次都可能会阻塞等待,这样效率并不高,还可能并发其他的问题;
2. 打通一次电话,直接把A,B,C的工作进展一次性想领导汇报;
这样就避免了阻塞等待的消耗了,也大大的提升了效率;
3.5 JUC常见组件
JUC是 Java.util.concurrent 的缩写,这里是多线程并发的一个类;
1. Callable接口
这里写一个程序去实现一下这个接口:
public class Demo16 { public static void main(String[] args) throws ExecutionException, InterruptedException { // 创建任务,计算从 1 加到 100 的和 Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 0; i < 100; i++) { sum += i; } return sum; } }; // 创建一个线程来完成这个任务 // Thread 不能直接传 callable, 外面需要再包装一层 FutureTask<Integer> futureTask = new FutureTask<>(callable); Thread thread = new Thread(); thread.start(); System.out.println(futureTask.get()); } }
上述代码也是线程创建的一种方法;
2. ReentrantLock类
可重入互斥锁 和 synchronized 定位类似,,都是用来实现互斥效果,,保证线程安全;
但是 synchronzied 是基于代码块实现来控制加锁和解锁,而 ReentrantLock 是提供了 lock 和unlock 的方法来进行加锁和解锁;
虽然 synchronized 已经在绝大多数情况下满足使用了,但是 ReentrantLock 也有自己特殊的方法:
- 使用 synchronized 加锁的时候如果发现锁被占有,会进入阻塞状态,而 ReentrantLock 提供了一个 tryLock 方法,如果发现锁被占用,不会阻塞,直接返回 false;
- synchronized 是一个非公平锁(不遵循规则),而 ReentrantLock 提供了公平和非公平两种工作模式;
- synchronized 搭配 wait 和 notify 进行唤醒等待,如果多个线程 wait 同一个对象,notify 的时候随机唤醒一个,而 ReentrantLock 搭配 Condition 这个类进行唤醒等待,并且它能指定一个线程唤醒;
四. 线程安全的集合类
4.1 HashTable 和 ConcurrentHashMap的区别
我们常用的ArrayList,LinkedList,HashMap等等这样的类都是线程不安全的,那如果我们在多线程环境下要使用,可能就会出问题;
针对这种情况,标准库里提供了线程安全版本的集合类,据我了解,从早期的线程安全的集合说起,它们是 Vector 和 HashTable:
Vector:
Vector 和 ArrayList 类似,是长度可变的数组,与 ArrayList 不同的是,Vector 是线程安全的,它给几乎所有的 public 方法都加上了 synchronized 关键字。由于加锁导致性能降低,在不需要并发访问同一对象时,这种强制性的同步机制就显得多余,所以现在 Vector 已被弃用;
CopyOnWriteArrayList 和 CopyOnWriteArraySet:
它们是加了写锁的 ArrayList 和 ArraySet,锁住的是整个对象,但读操作可以并发执行;
HashTable:
HashTable 和 HashMap类似,不同点是 HashTable 是线程安全的,它给几乎所有 public 方法都加上了 synchronized 关键字,还有一个不同点是 HashTable 的 K,V 都不能是 null ,但 HashMap 可以,它现在也因为性能原因被弃用了;
HashTable 和 ConcurrentHashMap的区别:
HashTable 是针对整个哈希表加锁,任何的 CURD 操作都可能会触发加锁,也可能有锁竞争;而 ConcurrentHashMap 是针对每个链表进行加锁,每次进行操作,都是针对对应链表进行加锁,操作不同链表就是针对不同的锁对象加锁,此时不会有锁冲突,没有锁冲突,就没有阻塞等待,这样也提升了效率;
4.2 多线程相关面试题
(1)谈谈 volatile关键字的用法?
volatile 能够保证内存可见性, 强制从主内存中读取数据,此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值;
(2)Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区,堆区,栈区,程序计数器,其中堆区这个内存区域是多个线程之间共享的,只要把某个数据放到堆内存中, 就可以让多个线程都能访问到;
(3)Java线程共有几种状态?状态之间怎么切换的?
- NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态;
- RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在CPU 上运行/在即将准备运行 的状态;
- BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态;
- WAITING: 调用 wait 方法会进入该状态;
- TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态;
- TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态;
(4)Thread和Runnable的区别和联系?
Thread 类描述了一个线程,Runnable 描述了一个任务,在创建线程的时候需要指定线 程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任 务;
(5)进程和线程的区别?
- 进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
- 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位