话不多说,干货走起。
面试第一题必问的 HashMap,挺考验Javaer的基础功底的,别问为啥放在这,因为重要!HashMap具有如下特性:
共存
的。key
,会强制性的判别出个高低,目的是为了决定向左还是向右放置数据。root
节点和链表的头节点 跟table[i]
节点融合成一个。RBT
类似,找个合适的节点来填充已删除的节点。root
节点不一定
跟table[i]
也就是链表的头节点是同一个,三者同步是靠MoveRootToFront
实现的。而HashIterator.remove()
会在调用removeNode
的时候movable=false
。常见HashMap考点:
ConcurrentHashMap 是多线程模式下常用的并发容器,它的实现在JDK7跟JDK8区别挺大的。
JDK7中的 ConcurrentHashMap 使用 Segment + HashEntry 分段锁实现并发,它的缺点是并发程度是由Segment 数组个数来决定的,并发度一旦初始化无法扩容,扩容的话只是HashEntry的扩容。
Segment 继承自 ReentrantLock,在此扮演锁的角色。可以理解为我们的每个Segment都是实现了Lock功能的HashMap。如果我们同时有多个Segment形成了Segment数组那我们就可以实现并发咯。
大致的put流程如下:
ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了
锁分离技术
。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,只要多个修改操作发生在不同的段上就可以并发进行。
用于存储键值对数据的HashEntry,在设计上它的成员变量value跟next都是
volatile
类型的,这样就保证别的线程对value值的修改,get方法可以马上看到,并且get的时候是不用加锁的。
比如迭代器在遍历数据的时候是一个Segment一个Segment去遍历的,如果在遍历完一个Segment时正好有一个线程在刚遍历完的Segment上插入数据,就会体现出不一致性。clear也是一样。get方法和containsKey方法都是遍历对应索引位上所有节点,都是不加锁来判断的,如果是修改性质的因为可见性的存在可以直接获得最新值,不过如果是新添加值则无法保持一致性。
size方法比较有趣,先无锁的统计所有的数据量看下前后两次是否数据一样,如果一样则返回数据,如果不一样则要把全部的segment进行加锁,统计,解锁。并且size方法只是返回一个统计性的数字。
ConcurrentHashMap 在JDK8中抛弃了分段锁,转为用 CAS + synchronized,同时将HashEntry改为Node,还加入了红黑树的实现,主要还是看put的流程(如果看了扩容这块,绝对可以好好吹逼一番)。
ConcurrentHashMap 是如果来做到高效并发安全
?
get方法中根本没有使用同步机制,也没有使用unsafe方法,所以读操作是支持并发操作的。
基本思路跟HashMap的写操作类似,只不过用到了CAS + syn 实现加锁,同时还涉及到扩容的操作。JDK8中锁已经细化到 table[i] 了,数组位置不同可并发,位置相同则去帮忙扩容。
并发编程的出发点:充分利用CPU计算资源,多线程并不是一定比单线程快,要不为什么Redis6.0版本的核心操作指令仍然是单线程呢?对吧!
多线程跟单线程的性能都要具体任务具体分析,talk is cheap, show me the picture。
进程:
进程是操作系统调用的最小单位,是系统进行资源分配和调度的独立单位。
线程:
并发:
concurrency : 多线程操作同一个资源,单核CPU极速的切换运行多个任务
并行:
parallelism :多个CPU同时使用,CPU多核 真正的同时执行
Java中线程的状态分为6种:
新创建了一个线程对象,但还没有调用start()方法。
就绪状态的线程在获得CPU时间片后变为运行中状态(running)。这也是线程进入运行状态的唯一的一种方式。
阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。
当线程正常运行结束或者被异常中断后就会被终止。线程一旦终止了,就不能复生。
PS:
阻塞:
当一个线程试图获取对象锁(非JUC库中的锁,即synchronized),而该锁被其他线程持有,则该线程进入阻塞状态。它的特点是使用简单,由JVM调度器来决定唤醒自己,而不需要由另一个线程来显式唤醒自己,不响应中断。
等待:
当一个线程等待另一个线程通知调度器一个条件时,该线程进入等待状态。它的特点是需要等待另一个线程显式地唤醒自己,实现灵活,语义更丰富,可响应中断。例如调用:Object.wait()、**Thread.join()**以及等待 Lock 或 Condition。
虽然 synchronized 和 JUC 里的 Lock 都实现锁的功能,但线程进入的状态是不一样的。synchronized 会让线程进入阻塞态,而 JUC 里的 Lock是用park()/unpark() 来实现阻塞/唤醒 的,会让线程进入等待状态。虽然等锁时进入的状态不一样,但被唤醒后又都进入Runnable状态,从行为效果来看又是一样的。
wait 来自Object,sleep 来自 Thread
wait 释放锁,sleep 不释放
wait 必须在同步代码块中,sleep 可以任意使用
wait 不需要捕获异常,sleep 需捕获异常
死锁是指两个或两个以上的线程互相持有对方所需要的资源,由于某些锁的特性,比如syn使用下,一个线程持有一个资源,或者说获得一个锁,在该线程释放这个锁之前,其它线程是获取不到这个锁的,而且会一直死等下去,因此这便造成了死锁。
面试官:你给我解释下死锁是什么,解释好了我就录用你。 应聘者:先发Offer,发了Offer我给你解释什么是死锁。
产生条件:
检查:
1、jps -l 定位进程号 2、jstack 进程号找到死锁问题
避免:
随着CPU、内存、磁盘的高速发展,它们的访问速度差别很大。为了提速就引入了L1、L2、L3三级缓存。以后程序运行获取数据就是如下的步骤了。
这样虽然提速了但是会导致缓存一致性问题
跟内存可见性问题
。同时编译器跟CPU为了加速也引入了指令重排。指令重排的大致意思就是你写的代码运行运算结果会按照你看到的逻辑思维去运行,但是在JVM内部系统是智能化的会进行加速排序的。
1、编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 2、指令级并行的重排序:现代处理器采用了指令级并行技术在不影响数据依赖性前提下重排。 3、内存系统的重排序:处理器使用缓存和读/写缓冲区 进程重排。
指令重排这种机制会导致有序性问题
,而在并发编程时经常会涉及到线程之间的通信跟同步问题,一般说是可见性
、原子性
、有序性
。这三个问题对应的底层就是 缓存一致性、内存可见性、有序性。
原子性:原子性就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。比如 a = 1。 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。Java保证可见性可以认为通过volatile、synchronized、final来实现。 有序性:程序执行的顺序按照代码的先后顺序执行,Java通过volatile、synchronized来保证。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存模式下多线程程序读写操作行为的规范,既JMM模型,注意JMM只是一个约定概念,是用来保证效果一致的机制跟规范。它作用于工作内存和主存之间数据同步过程,规定了如何做数据同步以及什么时候做数据同步。
在JMM中,有两条规定:
共享变量要实现可见性,必须经过如下两个步骤:
同时人们提出了内存屏障
、happen-before
、af-if-serial
这三种概念来保证系统的可见性
、原子性
、有序性
。
内存屏障 (Memory Barrier) 是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。具有如下功能:
在 volatile 中就用到了内存屏障,volatile部分已详细讲述。
因为有指令重排
的存在会导致难以理解CPU内部运行规则,JDK用 happens-before 的概念来阐述操作之间的内存可见性。在JMM 中如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before
关系 。其中CPU的happens-before
无需任何同步手段就可以保证的。
af-if-serial 的含义是不管怎么重排序(编译器和处理器为了提高并行度),单线程环境下程序的执行结果不能被改变且必须正确。该语义使单线程环境下程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
volatile 关键字的引入可以保证变量的可见性,但是无法保证变量的原子性,比如 a++ 这样的是无法保证的。这里其实涉及到JMM 的知识点,Java多线程交互是通过共享内存的方式实现的。当我们读写volatile变量时具有如下规则:
volatile就会用到上面说到的内存屏障,目前有四种内存屏障:
volatile原理:用volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令,在CPU级别的功能如下:
高频考点单例模式:就是将类的构造函数进行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;
}
}
不用Volatile则代码运行时可能存在指令重排,会导致线程一在运行时执行顺序是 1-->2--> 4 就赋值给instance变量了,然后接下来再执行构造方法初始化。问题是如果构造方法初始化执行没完成前 线程二进入发现instance != null,直接给线程二个半成品,加入volatile后底层会使用内存屏障强制按照你以为的执行。
单例模式几乎是面试必考点,,一般有如下特性:
懒汉式:在需要用到对象时才实例化对象,正确的实现方式是 Double Check + Lock + volatile,解决了并发安全和性能低下问题,对内存要求非常高,那么使用懒汉式写法。 饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题。 枚举式:Effective Java 这本书也列举了使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例。
老王是个深耕在帝都的一线码农,辛苦一年挣了点钱,想把钱存储到银行卡里,拿钱去银行办理遇到了如下的遭遇
直接办理
了。坐着等
办理去了。解决策略
。上面的这个流程几乎就跟JDK线程池的大致流程类似,其中7大参数:
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,一般有四大拒绝策略:
使用Executors创建线程池可能会导致OOM。原因在于线程池中的BlockingQueue主要有两种实现,分别是ArrayBlockingQueue 和 LinkedBlockingQueue。
正确创建线程池的方式就是自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池。在创建的同时,给BlockQueue指定容量就可以了。
private static ExecutorService executor = new ThreadPoolExecutor(10, 10,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue(10));
罗列几种常见的线程池创建方式。
定长的线程池,有核心线程,核心线程的即为最大的线程数量,没有非核心线程。 使用的无界的等待队列是LinkedBlockingQueue。使用时候小心堵满等待队列。
创建单个线程数的线程池,它可以保证先进先出的执行顺序
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行
最原始跟常见的创建线程池的方式,它包含了 7 个参数、4种拒绝策略 可用。
线程池 在工作中常用,面试也是必考点。关于线程池的细节跟使用在以前举例过一个 银行排队 办业务的例子了。线程池一般主要也无非就是下面几个考点了:
ThreadLocal 可以简单理解为线程本地变量,相比于 synchronized 是用空间来换时间的思想。他会在每个线程都创建一个副本,在线程之间通过访问内部副本变量的形式做到了线程之间互相隔离。这里用到了 弱引用 知识点:
如果一个对象只具有弱引用,那么GC回收器在扫描到该对象时,无论内存充足与否,都会回收该对象的内存。
每个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 操作。
Compare And Swap:比较并交换,主要是通过处理器的指令来保证操作的原子性
,它包含三个操作数:
V:变量内存地址 A:旧的预期值 B:准备设置的新值
当执行CAS指令时,只有当 V 对应的值等于 A 时才会用 B 去更新V的值,否则就不会执行更新操作。CAS可能会带来ABA问题、循环开销过大问题、一个共享变量原子性操作的局限性。如何解决以前写过,在此不再重复。
Synchronized 是 JDK自带的线程安全关键字,该关键字可以修饰实例方法、静态方法、代码块三部分。该关键字可以保证互斥性、可见性、有序性(不解决重排)但保证有序性。
Syn的底层其实是C++代码写的,JDK6前是重量级锁,调用的时候涉及到用户态跟内核态的切换,挺耗时的。JDK6之前 Doug Lea写出了JUC包,可以方便的让用于在用户态实现锁的使用,Syn的开发者被激发了斗志所以在JDK6后对Syn进行了各种性能升级。
Syn里涉及到了 对象头 包含对象头、填充数据、实例变量。这里可以看一个美团面试题:
问题一:new Object()占多少字节
问题二:User (int id,String name) User u = new User(1,"李四")
markword 8字节 + 开启classPointer压缩后classpointer 4字节 + instance data int 4字节 + 开启普通对象指针压缩后String4字节 + padding 4 = 24字节
synchronized 锁在JDK6以后有四种状态,无锁
、偏向锁
、轻量级锁
、重量级锁
。这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。大致升级过程如下(原图公众号回复syn
):
锁对比:
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁解锁无需额外消耗,跟非同步方法时间相差纳秒级别 | 如果竞争线程多,会带来额外的锁撤销的消耗 | 基本没有其他线程竞争的同步场景 |
轻量级锁 | 竞争的线程不会阻塞而是在自旋,可提高程序响应速度 | 如果一直无法获得会自旋消耗CPU | 少量线程竞争,持有锁时间不长,追求响应速度 |
重量级锁 | 线程竞争不会导致CPU自旋跟消耗CPU资源 | 线程阻塞,响应时间长 | 很多线程竞争锁,切锁持有时间长,追求吞吐量时候 |
指令重排是程序运行时 解释器 跟 CPU 自带的加速手段,可能导致语句执行顺序跟预想不一样情况,但是无论如何重排 也必须遵循 as-if-serial。
避免重排的最简单方法就是禁止处理器优化跟指令重排,比如volatile中用内存屏障实现,syn是关键字级别的排他且可重入锁,当某个线程执行到一段被syn修饰的代码之前,会先进行加锁,执行完之后再进行解锁。
当某段代码被syn加锁后跟解锁前,其他线程是无法再次获得锁的,只有这条加锁线程可以重复获得该锁。所以代码在执行的时候是单线程执行的,这就满足了as-if-serial语义,正是因为有了as-if-serial语义保证,单线程的有序性就天然存在了。
虚假唤醒定义:
虚假唤醒原因:
因为 if 只会执行一次,执行完会接着向下执行 if 下面的。而 while 不会,直到条件满足才会向下执行 while下面的。
虚假唤醒 解决办法:
在调用 wait 的时候要用 while 不能用 if。
synchronized 代码块通过 javap 生成的字节码中包含monitorenter 和 monitorexit 指令线程,执行 monitorenter 指令可以获取对象的 monitor,而 wait 方法通过调用 native 方法 wait(0) 实现,该注释说:The current thread must own this object's monitor。
notify/notifyAll 调用时并不会真正释放对象锁,只是把等待中的线程唤醒然后放入到对象的锁池中,但是锁池中的所有线程都不会立马运行,只有拥有锁的线程运行完代码块释放锁,别的线程拿到锁才可以运行。
public void test()
{
Object object = new Object();
synchronized (object){
object.notifyAll();
while (true){
// TODO 死循环会导致 无法释放锁。
}
}
}
目标是实现两个线程交替打印,实现字母在前数字在后。你可以用信号量、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();
}
}
上题我们用到了ReentrantLock、Condition ,但是它们的底层是如何实现的呢?其实他们是基于AQS的 同步队列 跟 等待队列 实现的!
学AQS 前 CAS + 自旋 + LockSupport + 模板模式 必须会,目的是方便理解源码,感觉比 Synchronized 简单,因为是单纯的 Java 代码。个人理解AQS具有如下几个特点:
ReentrantLock底层:
当我们调用 Condition 里的 await 跟 signal 时候底层其实是这样走的。
所有的变量都是在方法内部声明的,这些变量都处于栈封闭状态。方法调用的时候会有一个栈桢,这是一个独立的空间。在这个独立空间创建跟使用则绝对是安全的,但是注意不要返回该变量哦!
优先级低的线程总是得不到执行机会,一般要保证资源充足、公平的分配资源、防止持有锁的线程长时间执行。
多线程编程不要为了用而用,引入多线程后会引入额外的开销。量应用程序性能一般:服务时间、延迟时间、吞吐量、可伸缩性。做应用的时候可以一般按照如下步骤:
阿姆达尔定律中 a为并行计算部分所占比例,n为并行处理结点个数:
都看到这了,送你几个高频面试题吧。