H5W3
当前位置:H5W3 > java > 正文

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

话不多说,干货走起。

1、HashMap

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

面试第一题必问的 HashMap,挺考验Javaer的基础功底的,别问为啥放在这,因为重要!HashMap具有如下特性:

  1. HashMap 的存取是没有顺序的。
  2. KV 均允许为 NULL。
  3. 多线程情况下该类安全,可以考虑用 HashTable。
  4. JDk8底层是数组 + 链表 + 红黑树,JDK7底层是数组 + 链表。
  5. 初始容量和装载因子是决定整个类性能的关键点,轻易不要动。
  6. HashMap是懒汉式创建的,只有在你put数据时候才会 build。
  7. 单向链表转换为红黑树的时候会先变化为双向链表最终转换为红黑树,切记双向链表跟红黑树是共存的。
  8. 对于传入的两个key,会强制性的判别出个高低,目的是为了决定向左还是向右放置数据。
  9. 链表转红黑树后会努力将红黑树的root节点和链表的头节点 跟table[i]节点融合成一个。
  10. 在删除的时候是先判断删除节点红黑树个数是否需要转链表,不转链表就跟RBT类似,找个合适的节点来填充已删除的节点。
  11. 红黑树的root节点不一定跟table[i]也就是链表的头节点是同一个,三者同步是靠MoveRootToFront实现的。而HashIterator.remove()会在调用removeNode的时候movable=false。

常见HashMap考点:

  1. HashMap原理,内部数据结构。
  2. HashMap中的put、get、remove大致过程。
  3. HashMap中 hash函数实现。
  4. HashMap如何扩容。
  5. HashMap几个重要参数为什么这样设定。
  6. HashMap为什么线程不安全,如何替换。
  7. HashMap在JDK7跟JDK8中的区别。
  8. HashMap中链表跟红黑树切换思路。
  9. JDK7中 HashMap环产生原理。

2、ConcurrentHashMap

ConcurrentHashMap 是多线程模式下常用的并发容器,它的实现在JDK7跟JDK8区别挺大的。
2.1 JDK7
JDK7中的 ConcurrentHashMap 使用 Segment + HashEntry 分段锁实现并发,它的缺点是并发程度是由Segment 数组个数来决定的,并发度一旦初始化无法扩容,扩容的话只是HashEntry的扩容。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

Segment 继承自 ReentrantLock,在此扮演锁的角色。可以理解为我们的每个Segment都是实现了Lock功能的HashMap。如果我们同时有多个Segment形成了Segment数组那我们就可以实现并发咯。
大致的put流程如下

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

1.ConcurrentHashMap底层大致实现?

2.ConcurrentHashMap在并发下的情况下如何保证取得的元素是最新的?

3.ConcurrentHashMap的弱一致性体现在clear和get方法,原因在于没有加锁。

4.size 统计个数不准确

2.2 JDK8
ConcurrentHashMap 在JDK8中抛弃了分段锁,转为用 CAS +synchronized,同时将HashEntry改为Node,还加入了红黑树的实现,主要还是看put的流程(如果看了扩容这块,绝对可以好好吹逼一番)。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

ConcurrentHashMap 是如果来做到高效并发安全?
1.读操作

2.写操作

3.同步处理主要是通过syn和unsafe的硬件级别原子性这两种方式完成

3 、并发基础知识

并发编程的出发点:充分利用CPU计算资源,多线程并不是一定比单线程快,要不为什么Redis6.0版本的核心操作指令仍然是单线程呢?对吧!
多线程跟单线程的性能都要具体任务具体分析,talk is cheap, show me the picture。

3.1 进程跟线程
进程:

线程:

3.2 并行跟并发
并发:

并行:

3.3 线程几个状态

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

Java中线程的状态分为6种:
1.初始(New):

2.可运行(Runnable):

3.运行中(Running)

4.阻塞(Blocked):

5.等待(Waiting) 跟 超时等待(Timed_Waiting):

6.终止(Terminated):

PS:

3.4. 阻塞与等待的区别
阻塞:

等待:

虽然 synchronized 和 JUC 里的 Lock 都实现锁的功能,但线程进入的状态是不一样的。synchronized 会让线程进入阻塞态,而 JUC 里的 Lock是用park()/unpark() 来实现阻塞/唤醒 的,会让线程进入等待状态。虽然等锁时进入的状态不一样,但被唤醒后又都进入Runnable状态,从行为效果来看又是一样的。
3.5 yield 跟 sleep 区别

  1. yield 跟 sleep 都能暂停当前线程,都不会释放锁资源,sleep 可以指定具体休眠的时间,而 yield 则依赖 CPU的时间片划分。
  2. sleep方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会。yield方法只会给相同优先级或更高优先级的线程以运行的机会。
  3. 调用 sleep 方法使线程进入等待状态,等待休眠时间达到,而调用我们的yield方法,线程会进入就绪状态,也就是sleep需要等待设置的时间后才会进行就绪状态,而yield会立即进入就绪状态。
  4. sleep方法声明会抛出 InterruptedException,而 yield 方法没有声明任何异常
  5. yield 不能被中断,而sleep 则可以接受中断。
  6. sleep方法比yield方法具有更好的移植性(跟操作系统CPU调度相关)

3.6 wait 跟 sleep 区别
1.来源不同

2.是否释放锁

3.使用范围

4.捕捉异常

3.7 多线程实现方式

  1. 继承 Thread,实现run方法
  2. 实现 Runnable接口中的run方法,然后用Thread包装下。Thread是线程对象,Runnable 是任务,线程启动的时候一定是对象。
  3. 实现 Callable接口,FutureTask包装实现接口,Thread 包装FutureTask。Callable 与 Runnable的区别在于Callable的call方法有返回值,可以抛出异常,Callable有缓存。
  4. 通过线程池调用实现。
  5. 通过Spring的注解@Async 实现。

3.8 死锁
死锁是指两个或两个以上的线程互相持有对方所需要的资源,由于某些锁的特性,比如syn使用下,一个线程持有一个资源,或者说获得一个锁,在该线程释放这个锁之前,其它线程是获取不到这个锁的,而且会一直死等下去,因此这便造成了死锁。

产生条件:

检查:

避免:

4、JMM

4.1 JMM由来

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

随着CPU、内存、磁盘的高速发展,它们的访问速度差别很大。为了提速就引入了L1、L2、L3三级缓存。以后程序运行获取数据就是如下的步骤了。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

这样虽然提速了但是会导致缓存一致性问题跟内存可见性问题。同时编译器跟CPU为了加速也引入了指令重排。指令重排的大致意思就是你写的代码运行运算结果会按照你看到的逻辑思维去运行,但是在JVM内部系统是智能化的会进行加速排序的。

指令重排这种机制会导致有序性问题,而在并发编程时经常会涉及到线程之间的通信跟同步问题,一般说是可见性、原子性、有序性。这三个问题对应的底层就是 缓存一致性、内存可见性、有序性。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存模式下多线程程序读写操作行为的规范,既JMM模型,注意JMM只是一个约定概念,是用来保证效果一致的机制跟规范。它作用于工作内存和主存之间数据同步过程,规定了如何做数据同步以及什么时候做数据同步。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

在JMM中,有两条规定:

共享变量要实现可见性,必须经过如下两个步骤:

同时人们提出了内存屏障、happen-before、af-if-serial这三种概念来保证系统的可见性、原子性、有序性。
4.2 内存屏障
内存屏障 (Memory Barrier) 是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。具有如下功能:

在 volatile 中就用到了内存屏障,volatile部分已详细讲述。
4.3 happen-before
因为有指令重排的存在会导致难以理解CPU内部运行规则,JDK用happens-before 的概念来阐述操作之间的内存可见性。在JMM 中如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。其中CPU的happens-before无需任何同步手段就可以保证的。

4.4 af-if-serial
af-if-serial 的含义是不管怎么重排序(编译器和处理器为了提高并行度),单线程环境下程序的执行结果不能被改变且必须正确。该语义使单线程环境下程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5、volatile

volatile 关键字的引入可以保证变量的可见性,但是无法保证变量的原子性,比如 a++ 这样的是无法保证的。这里其实涉及到JMM 的知识点,Java多线程交互是通过共享内存的方式实现的。当我们读写volatile变量时具有如下规则:

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

volatile就会用到上面说到的内存屏障,目前有四种内存屏障:

volatile原理:用volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令,在CPU级别的功能如下:

6、单例模式 DCL + volatile

6.1 标准单例模式
高频考点单例模式:就是将类的构造函数进行private化,然后只留出一个静态的 Instance 函数供外部调用者调用。单例模式一般标准写法是 DCL + volatile:

public class SingleDcl {
private volatile static SingleDcl singleDcl; //保证可见性
private SingleDcl(){
}
public static SingleDcl getInstance(){
// 放置进入加锁代码,先判断下是否已经初始化好了
if(singleDcl == null) {
// 类锁 可能会出现 AB线程都在这卡着,A获得锁,B等待获得锁。
synchronized (SingleDcl.class) {
if(singleDcl == null) {
// 如果A线程初始化好了,然后通过vloatile 将变量复杂给住线程。
// 如果此时没有singleDel === null,判断 B进程 进来后还会再次执行 new 语句
singleDcl = new SingleDcl();
}
}
}
return singleDcl;
}
}
12345678910111213141516171819

6.2 为什么用Volatile修饰

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

不用Volatile则代码运行时可能存在指令重排,会导致线程一在运行时执行顺序是 1–>2–> 4 就赋值给instance变量了,然后接下来再执行构造方法初始化。问题是如果构造方法初始化执行没完成前 线程二进入发现instance != null,直接给线程二个半成品,加入volatile后底层会使用内存屏障强制按照你以为的执行。

单例模式几乎是面试必考点,,一般有如下特性:

7、线程池

7.1 五分钟了解线程池
老王是个深耕在帝都的一线码农,辛苦一年挣了点钱,想把钱存储到银行卡里,拿钱去银行办理遇到了如下的遭遇

  1. 老王银行门口取号后发现有柜台营业ing 但是没人办理业务就直接办理了。
  2. 老王取号后发现柜台上都有人在办理,等待席有空地,去坐着等办理去了。
  3. 老王取号后发现柜台都有人办理,等待席也人坐满了,这个时候银行经理看到老王是老实人本着关爱老实人的态度,新开一个临时窗口给他办理了。
  4. 老王取号后发现柜台都满了,等待座位席也满了,临时窗口也人满了。这个时候银行经理给出了若干解决策略。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

上面的这个流程几乎就跟JDK线程池的大致流程类似,其中7大参数:

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,一般有四大拒绝策略:

7.2 正确创建方式
使用Executors创建线程池可能会导致OOM。原因在于线程池中的BlockingQueue主要有两种实现,分别是ArrayBlockingQueue 和LinkedBlockingQueue。

正确创建线程池的方式就是自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池。在创建的同时,给BlockQueue指定容量就可以了。

private static ExecutorService executor = new ThreadPoolExecutor(10, 10,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue(10));
123

7.3 常见线程池
罗列几种常见的线程池创建方式。

1.Executors.newFixedThreadPool

2.Executors.newSingleThreadExecutor

3.Executors.newCachedThreadPool

4.Executors.newScheduledThreadPool

5.ThreadPoolExecutor

7.4 线程池核心点

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

线程池 在工作中常用,面试也是必考点。关于线程池的细节跟使用在以前举例过一个 银行排队 办业务的例子了。线程池一般主要也无非就是下面几个考点了:

  1. 为什么用线程池。
  2. 线程池的作用。
  3. 7大重要参数。
  4. 4大拒绝策略。
  5. 常见线程池任务队列,如何理解有界跟无界。
  6. 常用的线程池模版。
  7. 如何分配线程池个数,IO密集型 还是 CPU密集型。
  8. 设定一个线程池优先级队列,Runable类要实现可对比功能,任务队列使用优先级队列。

8、ThreadLocal

ThreadLocal 可以简单理解为线程本地变量,相比于 synchronized 是用空间来换时间的思想。他会在每个线程都创建一个副本,在线程之间通过访问内部副本变量的形式做到了线程之间互相隔离。这里用到了 弱引用 知识点:

8.1 核心点
每个Thread内部都维护一个ThreadLocalMap字典数据结构,字典的Key值是ThreadLocal,那么当某个ThreadLocal对象不再使用(没有其它地方再引用)时,每个已经关联了此ThreadLocal的线程怎么在其内部的ThreadLocalMap里做清除此资源呢?JDK中的ThreadLocalMap没有继承java.util.Map类,而是自己实现了一套专门用来定时清理无效资源的字典结构。其内部存储实体结构Entry<ThreadLocal, T>继承自java.lan.ref.WeakReference,这样当ThreadLocal不再被引用时,因为弱引用机制原因,当jvm发现内存不足时,会自动回收弱引用指向的实例内存,即其线程内部的ThreadLocalMap会释放其对ThreadLocal的引用从而让jvm回收ThreadLocal对象。这里是重点强调下,回收的是Key 也就是ThreadLocal对象,而非整个Entry,所以线程变量中的值T对象还是在内存中存在的,所以内存泄漏的问题还没有完全解决。

接着分析底层代码会发现在调用ThreadLocal.get() 或者 ThreadLocal.set() 都会 定期回收无效的Entry 操作。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

9、CAS

Compare And Swap:比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:

当执行CAS指令时,只有当 V 对应的值等于 A 时才会用 B 去更新V的值,否则就不会执行更新操作。CAS可能会带来ABA问题、循环开销过大问题、一个共享变量原子性操作的局限性。如何解决以前写过,在此不再重复。

10、Synchronized

10.1 Synchronized 讲解

Synchronized 是 JDK自带的线程安全关键字,该关键字可以修饰实例方法、静态方法、代码块三部分。该关键字可以保证互斥性、可见性、有序性(不解决重排)但保证有序性。

Syn的底层其实是C++代码写的,JDK6前是重量级锁,调用的时候涉及到用户态跟内核态的切换,挺耗时的。JDK6之前 Doug Lea写出了JUC包,可以方便的让用于在用户态实现锁的使用,Syn的开发者被激发了斗志所以在JDK6后对Syn进行了各种性能升级。

10.2 Synchronized 底层

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

Syn里涉及到了 对象头 包含对象头、填充数据、实例变量。这里可以看一个美团面试题:

问题一:new Object()占多少字节

问题二:User (int id,String name) User u = new User(1,“李四”)

10.3 Synchronized 锁升级
synchronized 锁在JDK6以后有四种状态,无锁、偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。大致升级过程如下(原图公众号回复syn):

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

锁对比:

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

10.4 Synchronized 无法禁止指令重排,却能保证有序性
指令重排是程序运行时 解释器 跟 CPU 自带的加速手段,可能导致语句执行顺序跟预想不一样情况,但是无论如何重排 也必须遵循 as-if-serial。

避免重排的最简单方法就是禁止处理器优化跟指令重排,比如volatile中用内存屏障实现,syn是关键字级别的排他且可重入锁,当某个线程执行到一段被syn修饰的代码之前,会先进行加锁,执行完之后再进行解锁。

当某段代码被syn加锁后跟解锁前,其他线程是无法再次获得锁的,只有这条加锁线程可以重复获得该锁。所以代码在执行的时候是单线程执行的,这就满足了as-if-serial语义,正是因为有了as-if-serial语义保证,单线程的有序性就天然存在了。
10.5 wait 虚假唤醒

虚假唤醒定义:

  1. 当一个条件满足时,很多线程都被唤醒了,但只有其中部分是有用的唤醒,其它的唤醒是不对的,
  2. 比如说买卖货物,如果商品本来没有货物,所有消费者线程都在wait状态卡顿呢。这时突然生产者进了一件商品,唤醒了所有挂起的消费者。可能导致所有的消费者都继续执行wait下面的代码,出现错误调用。

虚假唤醒原因:

虚假唤醒 解决办法:

10.6 notify()底层
1.为何wait跟notify必须要加synchronized锁

2.notify 执行后立马唤醒线程吗?

public void test()
{
Object object = new Object();
synchronized (object){
object.notifyAll();
while (true){
// TODO 死循环会导致 无法释放锁。
}
}
}
12345678910

11、AQS

11.1 高频考点线程交替打印
目标是实现两个线程交替打印,实现字母在前数字在后。你可以用信号量、Synchronized关键字跟Lock实现,这里用 ReentrantLock简单实现:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
private static Lock lock = new ReentrantLock();
private static Condition c1 = lock.newCondition();
private static Condition c2 = lock.newCondition();
private static CountDownLatch count = new CountDownLatch(1);
public static void main(String[] args) {
String c = "ABCDEFGHI";
char[] ca = c.toCharArray();
String n = "123456789";
char[] na = n.toCharArray();
Thread t1 = new Thread(() -> {
try {
lock.lock();
count.countDown();
for(char caa : ca) {
c1.signal();
System.out.print(caa);
c2.await();
}
c1.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
try {
count.await();
lock.lock();
for(char naa : na) {
c2.signal();
System.out.print(naa);
c1.await();
}
c2.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
t1.start();
t2.start();
}
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455

11.2 AQS底层

上题我们用到了ReentrantLock、Condition ,但是它们的底层是如何实现的呢?其实他
们是基于AQS的 同步队列 跟 等待队列 实现的!

11.2.1 AQS 同步队列
学AQS 前 CAS + 自旋 + LockSupport + 模板模式 必须会,目的是方便理解源码,感觉比 Synchronized 简单,因为是单纯的 Java 代码。个人理解AQS具有如下几个特点:

  1. 在AQS 同步队列中 -1 表示线程在睡眠状态
  2. 当前Node节点线程会把前一个Node.ws =-1。当前节点把前面节点ws设置为-1,你可以理解为:你自己能知道自己睡着了吗?只能是别人看到了发现你睡眠了!
  3. 持有锁的线程永远不在队列中。
  4. 在AQS队列中第二个才是最先排队的线程。
  5. 如果是交替型任务或者单线程任务,即使用了Lock也不会涉及到AQS 队列。
  6. 不到万不得已不要轻易park线程,很耗时的!所以排队的头线程会自旋的尝试几个获取锁。
  7. 并不是说 CAS一定比SYN好,如果高并发执行时间久 ,用SYN好, 因为SYN底层用了wait()阻塞后是不消耗CPU资源的。如果锁竞争不激烈说明自旋不严重 此时用CAS。
  8. 在AQS中也要尽可能避免调用CLH队列,因为CLH可能会调用到park,相对来耗时。

ReentrantLock底层:

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

11.2.2 AQS 等待队列
当我们调用 Condition 里的 await 跟 signal 时候底层其实是这样走的。

【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

12、线程思考

12.1. 变量建议使用栈封闭

12.2. 防止线程饥饿

12.3 开发步骤

多线程编程不要为了用而用,引入多线程后会引入额外的开销。量应用程序性能一般:服务时间、延迟时间、吞吐量、可伸缩性。做应用的时候可以一般按照如下步骤:

  1. 先确保保证程序的正确性跟健壮性,确实达不到性能要求再想如何提速。
  2. 一定要以测试为基准。
  3. 一个程序中串行的部分永远是有的.
  4. 装逼利器:阿姆达尔定律 S=1/(1-a+a/n)

阿姆达尔定律中 a为并行计算部分所占比例,n为并行处理结点个数:

  1. 当1-a=0时,(即没有串行,只有并行)最大加速比s=n;
  2. 当a=0时(即只有串行,没有并行),最小加速比s=1;
  3. 当n无穷大时,极限加速比s→ 1/(1-a),这就是加速比的上限。例如,若串行代码占整个代码的25%,则并行处理的总体性能不可能超过4。

12.4 影响性能因素

  1. 缩小锁的范围,能锁方法块尽量不要锁函数
  2. 减少锁的粒度跟锁分段,比如ConcurrentHashMap的实现。
  3. 读多写少时候用读写锁,可提高十倍性能。
  4. 用CAS操作来替换重型锁。
  5. 尽量用JDK自带的常见并发容器,底层已经足够优化了。

13、End

都看到这了,送你几个高频面试题吧。

  1. synchronized跟ReentrantLock使用区别跟底层实现以及重入底层原理
  2. 描述下锁的四种状态跟升级过程
  3. CAS是什么?CAS的弊端是什么?
  4. 你对volatile的理解,可见性跟指令重排咋实现的。
  5. 一个对象创建过程是怎么样的。对象在内存中如何分布的,看 JVM 即可。
  6. 聊一聊单例模式,为什么DCL要用volatile
  7. Object 0 = new Object() 在内存中占据几个字节
  8. 你对as-if-serial跟happpends-before的理解
  9. ThreadLocal说一说,咋解决内存泄露
  10. 自旋锁一定比重量级锁效率高吗?偏向锁是否效率一定提高。
  11. 线程池聊一聊如何用注意细节,如何实现。
  12. 你对JMM理解?
  13. Synchronized 可以实现指令重排么?它是如何保证有序性的?
  14. 聊一聊AQS,为什么AQS 底层是 CAS + Volatile

个人观点,欢迎留言写下你的见解!!

本文地址:H5W3 » 【Java】JAVA并发十二连招,你能接住吗?(建议收藏!!)

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址