JavaEE - 多线程(进阶)

常见的锁策略

锁策略,和普通程序猿基本没啥关系.和"实现锁"的人才有关系的~
这里所提到的锁策略,和Java本身没关系.适用于所有和"锁”相关的情况~~

1.悲观锁vs乐观锁

悲观锁:预期锁冲突的概率很高.

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁:预期锁冲突的概率很低.

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.

假设数据一般情况下不会产生并发冲突, 并不会真的加锁。所以在数据进行提交更新的时候(访问数据),才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

悲观锁,做的工作更多,付出的成本更多,更低效.
乐观锁,做的工作更少,付出的成本更低,更高效.

2.读写锁vs普通的互斥锁

普通的互斥锁只有两个操作,加锁和解锁~
只要两个线程针对同一个对象加锁,就会产生互斥~~

对于读写锁来说,分成了三个操作.
加读锁 -> 如果代码只是进行读操作,就加读锁
加写锁-> 如果代码中进行了修改操作,就加写锁
解锁

一个线程对于数据的访问,主要存在两种操作:读数据和写数据.

  • 两个线程都只是读一个数据,此时并没有线程安全问题.直接并发的读取即可.
  • 两个线程都要写一个数据,有线程安全问题.
  • 一个线程读另外一个线程写,也有线程安全问题.

读写锁就是把读操作和写操作分别进行加锁.

  • 读锁和读锁之间不互斥.
  • 写锁和写锁之间互斥.
  • 写锁和读锁之间互斥.

读写锁最主要用在 "频繁读,不频繁写"的场景中.
多线程同时读同—个变量不会有线程安全问题!!
而且在很多场景中,都是读操作多,写操作少~(数据库索引)

3.重量级锁vs轻量级锁

(和上面的悲观乐观有一定重叠)
悲观锁和乐观锁相当于是处理锁冲突的态度(原因)
重量级锁和轻量级锁相当于是处理锁冲突的结果

重量级锁,就是做了更多的事情,开销更大.
轻量级锁,做的事情更少,开销更小.

也可以认为,通常情况下,悲观锁─般都是重量级锁,乐观锁─般都是轻量级锁.(不绝对)

  • 如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的
    mutex接口),此时一般认为这是重量级锁.(操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)
  • 如果锁是纯用户态实现的,此时一般认为这是轻量级锁(用户态的代码更可控,也更高效)

4.挂起等待锁vs自旋锁

  • 挂起等待锁,往往就是通过内核的一些机制来实现的.往往较重.[重量级锁的一种典型实现]
  • 自旋锁,往往就是通过用户态代码来实现的.往往教轻.[轻量级锁的一种典型实现]

挂起等待锁
如果获取锁失败, 不在再尝试获取锁, 而是进入等待状态.

自旋锁
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.

相比于挂起等待锁,

优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.(而挂起等待的时候是不消耗 CPU 的).

理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.

上述的这些锁策略,彼此之间并不是完全独立的.

5.公平锁vs非公平锁

公平锁:多个线程在等待一把锁的时候~~,谁是先来的,谁就能先获取到这个锁(遵守先来后到)

非公平锁:多个线程在等待—把锁的时候,不遵守先来后到~~(每个等待的线程获取到锁的概率都是均等的)

6.理解用户态 vs内核态

在这里插入图片描述
举个例子:
在这里插入图片描述

相关面试题

1)介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 "频繁读,不频繁写"的场景中.

2) synchronized是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数).如果发现当前加锁的线程就是持有锁的线程,则直接计数自增.
在这里插入图片描述
3) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.

乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.

乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上
面的图).

4) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场
景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.

Synchronized原理

基本特点

结合上面的锁策略,我们就可以总结出, Synchronized具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁.(根据锁竞争的激烈程度,自适应)
  2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁.(如果锁冲突比较严重,就会变成重量级锁)(根据锁竞争的激烈程度,自适应)
  3. 实现轻量级锁的时候大概率用到的自旋锁策略,重量级锁的部分基于挂起等待锁来实现
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 是一种普通的互斥锁,不是读写锁

加锁工作过程

JVM将 synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。
在这里插入图片描述

1)偏向锁

第一个尝试加锁的线程,优先进入偏向锁状态.
偏向锁不是真的 “加锁”,只是给对象头中做一个 “偏向锁的标记”,记录这个锁属于哪个线程.

  • 如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)
  • 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态.

偏向锁本质上相当于 “延迟加锁” .能不加锁就不加锁,尽量来避免不必要的加锁开销.
但是该做的标记还是得做的,否则无法区分何时需要真正加锁.

举个栗子理解偏向锁

假设男主是一个锁,女主是一个线程.如果只有这一个线程来使用这个锁,那么男主女主即使不领证结婚(避免了高成本操作),也可以一直幸福的生活下去.但是女配出现了,也尝试竞争男主,此时不管领证结婚这个操作成本多高,女主也势必要把这个动作完成了,让女配死心.

2)轻量级锁

随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS来实现.

  • 通过 CAS检查并更新一块内存 (比如 null =>该线程引用)
  • 如果更新成功,则认为加锁成功
  • 如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃 CPU).

自旋操作是一直让 CPU空转,比较浪费 CPU资源.
因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不再自旋了,也就是所谓的 “自适应”

3)重量级锁

如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .

  • 执行加锁操作,先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用,则加锁成功,并切换回用户态.
  • 如果该锁被占用,则加锁失败.此时线程进入锁的等待队列,挂起.等待被操作系统唤醒.
  • 经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁.

synchronized中的锁优化机制

1.锁膨胀/锁升级

体现了synchronized 能够“自适应"这样的能力~

在这里插入图片描述

2.锁粗化/细化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

此处的粗细指的是“锁的粒度".

加锁代码涉及到的范围.

  • 加锁代码的范围越大,认为锁的粒度越粗
  • 范围越小,则认为粒度越细~

到底锁粒度是粗好还是细好?

  • 如果锁粒度比较细,多个线程之间的并发性就更高
  • 如果锁粒度比较粗,加锁解锁的开销就更小

3.锁消除

有些代码,明明不用加锁,结果你给加上锁了.
编译器就会发现这个加锁好像没啥必要,就直接把锁给去掉了~~

相关面试题

1)什么是偏向锁?
偏向锁不是真的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程).如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销.一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态.
2) synchronized实现原理是什么?
参考上面的 synchronized 原理章节全部内容.

Callable接口

Callable的用法

Callable是一个 interface .相当于把线程封装了一个 “返回值”.方便程序猿借助多线程的方式计算结果.

代码示例:创建线程计算 1 + 2 + 3 + … + 1000,不使用 Callable版本

  • 创建一个类 Result ,包含一个 sum表示最终结果, lock表示线程同步使用的锁对象.
  • main方法中先创建 Result实例,然后创建一个线程 t.在线程内部计算 1 + 2 + 3 + … + 1000.
  • 主线程同时使用 wait等待线程 t计算结束. (注意,如果执行到 wait之前,线程 t已经计算完了,就不必等待了).
  • 当线程 t计算完毕后,通过 notify唤醒主线程,主线程再打印结果.
static class Result {
   public int sum = 0;
   public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
   Result result = new Result();
   Thread t = new Thread() {
       @Override
       public void run() {
           int sum = 0;
           for (int i = 1; i <= 1000; i++) {
               sum += i;
          }
           synchronized (result.lock) {
               result.sum = sum;
               result.lock.notify();
          }
      }
  };
   t.start();
   synchronized (result.lock) {
       while (result.sum == 0) {
           result.lock.wait();
      }
       System.out.println(result.sum);
  }
}

可以看到,上述代码需要一个辅助类 Result,还需要使用一系列的加锁和 wait notify操作,代码复杂,容易出错.

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000,使用 Callable版本

  • 创建一个匿名内部类,实现 Callable接口. Callable带有泛型参数.泛型参数表示返回值的类型.
  • 重写 Callable的 call方法,完成累加的过程.直接通过返回值返回计算结果.
  • 把 callable实例使用 FutureTask包装一下.
  • 创建线程,线程的构造方法传入 FutureTask .此时新线程就会执行 FutureTask内部的
    Callable的call方法,完成计算.计算结果就放到了 FutureTask对象中.
  • 在主线程中调用 futureTask.get()能够阻塞等待新线程计算完毕.并获取到 FutureTask中的结果.
Callable<Integer> callable = new Callable<Integer>() {
   @Override
   public Integer call() throws Exception {
       int sum = 0;
       for (int i = 1; i <= 1000; i++) {
           sum += i;
      }
       return sum;
  }
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);

可以看到,使用 Callable和 FutureTask之后,代码简化了很多,也不必手动写线程同步代码了.

理解 Callable
Callable和 Runnable相对,都是描述一个 “任务”. Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务.Callable通常需要搭配 FutureTask来使用. FutureTask用来保存 Callable的返回结果.因为Callable往往是在另一个线程中执行的,啥时候执行完并不确定.FutureTask就可以负责这个等待结果出来的工作.

理解 FutureTask
想象去吃麻辣烫.当餐点好后,后厨就开始做了.同时前台会给你一张 “小票” .这个小票就是FutureTask.后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.

相关面试题

介绍下 Callable是什么

Callable是一个 interface .相当于把线程封装了一个 “返回值”.方便程序猿借助多线程的方式计算结果.
Callable和 Runnable相对,都是描述一个 “任务”. Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务.
Callable通常需要搭配 FutureTask来使用. FutureTask用来保存 Callable的返回结果.因为Callable往往是在另一个线程中执行的,啥时候执行完并不确定.
FutureTask就可以负责这个等待结果出来的工作.

CAS

什么是 CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功

在这里插入图片描述
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

CAS 有哪些应用

1)实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

2) 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
在这里插入图片描述

CAS 的 ABA 问题

什么是 ABA 问题

在这里插入图片描述

解决方案

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

  • CAS 操作在读取旧值的同时, 也要读取版本号.
  • 真正修改的时候,
    • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
    • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的手机记为版本1,以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意这是翻新机, 就买. 如果买家在意, 就可以直接略过.

相关面试题

1) 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.

2) ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

  • 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增;
  • 如果发现当前版本号比之前读到的版本号大, 就认为操作失败.

JUC(java.util.concurrent)的常见类

可重入互斥锁 ReentrantLock

可重入互斥锁.和 synchronized定位类似,都是用来实现互斥效果,保证线程安全.ReentrantLock也是可重入锁. "Reentrant"这个单词的原意就是 “可重入”

ReentrantLock的用法:

  • lock():加锁,如果获取不到锁就死等.
  • trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁.
  • unlock():解锁
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();  
try {    
	// working    
} finally {    
	lock.unlock()    
}  

ReentrantLock和 synchronized的区别

  • synchronized是一个关键字,是 JVM内部实现的(大概率是基于 C++实现). ReentrantLock是标准库的一个类,在JVM外实现的(基于 Java实现).
  • synchronized使用时不需要手动释放锁.ReentrantLock使用时需要手动释放.使用起来更灵活,但是也容易遗漏unlock.
  • synchronized在申请锁失败时,会阻塞等待(死等). ReentrantLock可以通过 trylock的方式,获取不到锁,等待一定的时间之后就放弃加锁…
  • synchronized是非公平锁, ReentrantLock默认是非公平锁.可以通过构造方法传入一个 true开启公平锁模式
  • 更强大的唤醒机制. synchronized是通过 Object的 wait / notify实现等待-唤醒.每次唤醒的是一个随机等待的线程.ReentrantLock搭配 Condition类实现等待-唤醒,可以更精确控制唤醒某个指定的线程.

如何选择使用哪个锁?

  • 锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便.
  • 锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock更灵活控制加锁的行为,而不是死等.
  • 如果需要使用公平锁,使用 ReentrantLock.

原子类 AtomInteger

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。

在这里插入图片描述

信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
理解信号量

可以把信号量想象成是停车场的展示牌:

当前有车位 100 个. 表示有 100 个可用资源. 当有车开进去的时候,就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)

当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就+1 (这个称为信号量的 V 操作)

如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

在这里插入图片描述

CountDownLatch

同时等待 N 个任务执行结束.

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

提供了两个方法:

  • countDown给每个线程里面去调用,就表示到达终点了.
  • await 是给等待线程去调用.当所有的任务都到达终点了, await就从阻塞中返回~就表示任务完成~

相关面试题

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)信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示 “可用资源的个数”.本质上就是一个计数器.
使用信号量可以实现 “共享锁”,比如某个资源允许 3个线程同时使用,那么就可以使用 P操作作为加锁, V操作作为解锁,前三个线程的 P操作都能顺利返回,后续线程再进行 P操作就会阻塞等待,直到前面的线程执行了 V操作.

线程安全的集合类

原来的集合类,大部分都不是线程安全的.
Vector, Stack, HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的.

多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized或者 ReentrantLock)
    前面做过很多相关的讨论了.此处不再展开.

  2. Collections.synchronizedList(new ArrayList);
    synchronizedList是标准库提供的一个基于 synchronized进行线程同步的 List.
    synchronizedList的关键操作上都带有 synchronized

  3. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
  • 添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:
在读多写少的场景下,性能很高,不需要加锁竞争.
缺点:
1.占用内存较多.
2.新写的数据不能被第一时间读取到.

在这里插入图片描述

多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列
  2. LinkedBlockingQueue
    基于链表实现的阻塞队列
  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列
  4. TransferQueue
    最多只包含一个元素的阻塞队列

多线程环境使用哈希表

HashMap本身不是线程安全的.
在多线程环境下使用哈希表可以使用:

  • Hashtable(不推荐)
  • ConcurrentHashMap(推荐)

1) Hashtable(不推荐)

在这里插入图片描述

  1. 只是简单的把关键方法加上了 synchronized关键字
  2. 一个Hashtable只有一把锁.两个线程访问Hashtable 中的任意数据都会出现锁竞争
  3. size属性也是通过 synchronized来控制同步,也是比较慢的.(size属性记录了哈希表的大小)
  4. 一旦触发扩容,就由该线程完成整个扩容过程.这个过程会涉及到大量的元素拷贝,效率会非常低.

2) ConcurrentHashMap(推荐)

ConcurrentHashMap每个哈希桶都有一把锁.
只有两个线程访问的恰好是同一个哈希桶上的数据才出现锁冲突。

  1. ConcurrentHashMap 减少了锁冲突,就让锁加到每个链表的头结点上(锁桶)
  2. ConcurrentHashMap 只是针对写操作加锁了.读操作没加锁.而只是使用
  3. ConcurrentHashMap 中更广泛的使用CAS,进一步提高效率~(比如维护size操作)
  4. ConcurrentHashMap 针对扩容,进行了巧妙的化整为零。
    1. 对于HashTable 来说只要你这次put触发了扩容就一口气搬运完. 会导致这次put非常卡顿.
    2. 对于ConcurrentHashMap,每次操作只搬运一点点.通过多次操作完成整个搬运的过程.同时维护一个新的 HashMap和一个旧的.查找的时候既需要查旧的也要查新的.插入的时候只插入新的直到搬运完毕再销毁旧的~

3) 举个例子

1.Hashtable
在这里插入图片描述
2.ConcurrentHashMap
在这里插入图片描述

相关面试题

1) ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁.目的是为了进一步降低锁冲突的概率.为了保证读到刚修改的数据,搭配了volatile关键字.

2)介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7中采取的技术. Java1.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.一个线程加两次锁
2.两个线程两把锁,互相加锁
3.多个线程 哲学家就餐

为了进一步阐述死锁的形成,很多资料上也会谈论到 “哲学家就餐问题”.

  • 有个桌子,围着一圈哲♂家,桌子中间放着一盘意大利面.每个哲学家两两之间,放着一根筷子.
    在这里插入图片描述
  • 每个哲♂家只做两件事:思考人生或者吃面条.思考人生的时候就会放下筷子.吃面条就会拿起左右两边的筷子(先拿起左边,再拿起右边).

在这里插入图片描述

  • 如果哲♂家发现筷子拿不起来了(被别人占用了),就会阻塞等待.

在这里插入图片描述

  • [关键点在这]假设同一时刻,五个哲♂家同时拿起左手边的筷子,然后再尝试拿右手的筷子,就会发现右手的筷子都被占用了.由于哲♂家们互不相让,这个时候就形成了死锁

在这里插入图片描述

死锁是一种严重的 BUG!!导致一个程序的线程 “卡死”,无法正常工作!

如何避免死锁

死锁产生的四个必要条件:

  • 互斥使用,一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)
  • 不可抢占,一个锁被一个线程占用了之后,其他的线程不能把这个锁给抢走,只能由锁占有者主动释放。
  • 请求和保持,即当资源请求者在请求其他的锁的同时,当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是被该线程持有的(保持原有的锁)。
  • 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
其中最容易破坏的就是 “循环等待”.两个线程对于加锁的顺序没有约定,就容易产生环路等待.

如何避免死锁 :破坏循环等待,对加锁顺序进行约定(锁排序)

最常用的一种死锁阻止技术就是锁排序.

假设有 N个线程尝试获取 M把锁,就可以针对 M把锁进行编号(1, 2,3…M).N个线程尝试获取锁的时候,都按照固定的按编号由小到大顺序来获取锁.这样就可以避免环路等待.

相关面试题

谈谈死锁是什么,如何避免死锁,避免算法?实际解决过没有?
参考整个 "死锁"章节

其他常见面试题

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)操作系统分配资源使用的银行家算法是什么?

一个银行家如何将一定数目的资金安全地借给若干个客户,使这些客户既能借到钱完成要干的事,同时银行家又能收回全部资金而不至于破产。

银行家算法需要确保以下四点:

  1. 当一个顾客对资金的最大需求量不超过银行家现有的资金时就可接纳该顾客;
  2. 顾客可以分期贷款, 但贷款的总数不能超过最大需求量;
  3. 当银行家现有的资金不能满足顾客尚需的贷款数额时,对顾客的贷款可推迟支付,但总能使顾客在有限的时间里得到贷款;
  4. 当顾客得到所需的全部资金后,一定能在有限的时间里归还所有的资金。

11)引起线程阻塞有哪些原因?阻塞线程怎么被唤醒?
线程阻塞的原因,有三大类:

  1. 获取锁,得不到,就进入到等待锁的阻塞队列只有占有锁的线程,释放锁时,才会唤醒这个队列中的线程。(notify不会唤醒该队列中的线程)

  2. 正在占用锁的线程,调用了wait,就进入wait阻塞队列只有obj.notify()方法,才会唤醒这个队列中的线程。

  3. 正在执行中的线程,调用了sleep()、join()或者IO,就进入了另一个阻塞队列。睡眠时间到,或者IO阻塞结束,线程才能得以继续进入可运行状态。

12)Java 中有没有什么方法能够唤醒阻塞Io?
首先,如果是IO阻塞,普通方法无法终止线程,第二,如果线程是因为调用wait,sleep等方法而进入阻塞状态,可以使用中断线程,并且抛出InterruptedException异常来唤醒它。

(1)会抛出InterruptedException的方法:wait、sleep、join等,针对这类方法,我们在线程内部处理好异常(要么完全内部处理,要么把这个异常抛出去),然后就可以实现唤醒。

(2)不会抛InterruptedException的方法:Socket的I/O,同步I/O,Lock.lock等。对于I/O类型,我们可以关闭他们底层的通道,比如Socket的I/O,关闭底层套接字,然后抛出异常处理就好了;比如同步I/O,关闭底层Channel然后处理异常。对于Lock.lock方法,我们可以改造成Lock.lockInterruptibly方法去实现。(如果线程遇到了阻塞IO ,无能为力,因为IO 是操作系统实现的,Java 代码并没有办法直接接触到操作系统

阻塞IO模型
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。当用户线程发出IO请求之后,内核回去看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才会接触block状态。
典型的阻塞IO模型的例子为:data = socket.read() 如果数据没有就绪,就会一直阻塞在read方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值