← 全部文章

Java 并发编程面试笔记

Java20 min read

目录

Hashtable和ConcurrentHashMap的区别?它们两个怎么实现的线程安全?

- hashtable就是在所有的方法上加上sychronized,性能一般
- ConcurrentHashMap现在最好用的,能保证现场安全,主要是通过CAS + synchronized实现的。
    - 查询时,直接查就行了
    - put时,存放数据时,通过hash定位好具体的数组下标位置,通过CAS的方式进行存放。当遇到hash冲突时,就锁住当前链表,使用synchronized的方式。
    - 扩容时,CHM的扩容与HashMap不太一样,CHM多线程并发时多个线程会帮助其进行扩容,当查询数据时正好在扩容,会先查旧数组的数据,如果该数据hash值是MOVED(-1),表示其已经被扩容到新的数组中了,去新数组中再查就行了。

都是线程安全的双列集合。

线程安全:

  • Hashtable:这哥们是完全给当前类的几乎所有方法都追加了synchronized,几乎,甚至不可能使用这个双列集合,效率太低了。
  • ConcurrentHashMap:他的线程安全咱们细致一些的话,要从三个维度去聊。
    • 写入数据:CAS + synchronized。
      • 当数据要落到数组上时,会基于CAS的方式,尝试将数组上的null替换为你要查询的Node对象。
      • 当数据要挂到链表或者红黑树上时,会基于synchronized锁住当前数组位置,桶。不影响数组其他索引位置的操作。
    • 扩容过程:扩容时,CHM不会锁住当前所有操作,会让其他写线程来协助扩容,尽快完成。
      • 基于一个核心属性sizeCtl:来控制当前并发扩容的线程个数,避免线程扩容的目的不一致。而且利用CAS的方式去操作。
      • CHM在迁移数据时,至少会将数组以16这个数值进行切分,每个线程每次从后往前只迁移16的长度,慢慢迁移,这样就规避了多个线程操作同一段内容。
    • 查询数据:是不会出现阻塞问题的,但是他依然是线程安全的。
      • 正常模式下,没啥说的,去找数组,或者链表查询对应数据。
      • CHM正在扩容,如果老数组有数值,那就可以直接查询到,如果数据被迁移到了新数组,老数组的位置上会留下一个MOVED标记,hash为-1,就可以直接去新数组查询数据。
      • 如果查询的桶里是红黑树,红黑树再被写入数据时,可能会存在左旋,右旋,变色之类的操作,会改变红黑树的结构,如果此时有一个读操作进来,可能会导致这个读操作查询不到准确的数据。所以TreeNode中不但有红黑树,还保留了双向链表,在出现红黑树被写入,同时需要查询时,查询操作会查询双向链表。反过来,在TreeBin中还实现了一个小小锁机制,利用CAS跟一个int类型操作,去保证有读线程在操作时,写线程会挂起等待。image.png

数据结构:

  • Hashtable:数组 + 链表
  • CHM:数组 + 链表 + 红黑树

ConcurrentHashMap负载因子的作用

CHM的负载因子是固定0.75,使用了final进行修饰,不允许修改,这点与HashMap不一样,HashMap是允许修改的,负载因子的作用就是数组扩容时使用。

负载因子就这东西

private static final float LOAD_FACTOR = 0.75f;

就是计算下次扩容的阈值,比如CHM的数组长度是16,扩容的阈值就是 16 * LOAD_FACTOR = 12。


其次,CHM中的LOAD_FACTOR是不允许修改的,他是被final修饰的。

而且在代码中,压根没用这个属性,CHM为了提升性能,没有用 * 这种操作,他直接用的

n - (n >>> 2)

本质跟 * 0.75效果一样。 因为HashMap可以是可以改的!!!


HashMap让改,如果改了,会有什么影响???

其实,负载因子更多的是空间跟时间的互换~~

比如负载因子设置为0.5,数组最多使用一半的空间,空间利用率不好,但是数组长,数据少,hash冲突会减少,查询效率就会变高!

比如负载因子设置为1,数组对多情况下占满,空间利用率好,但是数组长,数据也多,hash冲突会更多,查询效率就降低!

CAS干嘛的?CAS的问题?

- CAS是CPU中的一个指令,能够实现并发,是乐观锁的一种实现,其实就是当要修改一个值的时候,使用当前值和期望值进行比较,如果相同就修改为最新的值,否则不进行修改。
- CAS的问题有ABA的问题,即多线程修改同一个数据时,数据由A变成了B,由B又变成了A,这时候另一个线程来修改时发现是A则又进行了修改,其实这时候的A已经不是最原始的A了,解决方式可以加上版本号进行限制。
- CAS还有一个问题就是长时间自旋获取锁,并发高的时候长时间获取占用CPU资源,可以在业务代码实现时加上超时时间限制。

CAS是Java中乐观锁的一种实现。CAS不是Java级别的,是CPU的一个指令,这个指令可以将 比较和交换 用一条指令实现,满足了原子性。他的特点是修改一个属性的值这个操作

 cmpxchg

Java中使用:

Java中执行这个指令是基于Unsafe类去实现。

var1:哪个对象
var2:属性在内存地址中的偏移量
var4:oldValue
var5:newValue
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

其次,CAS到底有没有锁操作?

其实本质上,他依然是有锁这个操作的。只是这个锁级别不在Java层面。

而是CPU层面,因为CPU是多核的,如果多核并行执行cmpxchg指令,没有限制的话,依然会存在原子性的问题。所以在多核CPU的情况下,会在cmpxchg指令前,追加lock前缀指令,利用缓存行锁或者CPU的总线锁确保cmpxchg修改同一个属性时,是原子性的。


CAS的问题:ABA、自旋次数过多…………

ABA:

ABA不一定是一个问题,如果你对于数据,只关注他的值,那ABA其实不是问题。如果既要关注值,同时要需要关注期间有无其他操作,那就需要关注了。

正常CAS操作。Unsafe去玩,如果需要对值额外增加版本号的匹配,可以直接使用

AtomicStampedReference

他仅仅是在CAS的过程,额外比对一个版本号的信息,这个版本号可以是v1,v2也可以是时间戳。。。

AtomicLong,AtomicInteger都没关注ABA问题,正常用嘛,也没事~~

自旋次数过多:

因为CAS不会导致线程挂起,他会一直执行cmpxchg指令,执行指令就需要CPU来调度,如果cmpxchg一直不成功,但是你还一直重试。。。那这不是浪费CPU资源嘛。。。。

解决方案很多,根据业务来:

  • 类似synchronized,CAS几次后拿不到,挂起线程,等待资源!
  • 也可以指定超时时间,一定时间范围内,不成功,就失败!
  • LongAdder,将CAS操作的值,做一个类似分片的效果,正常CAS针对一个值,现在提供多个值。

synchronized的底层实现。锁升级。

- 其本质是锁住对象
- 偏向锁 --> 轻量级锁 --> 重量级锁

几个维度去聊synchronized的底层实现:

  • 基于对象实现的,synchronized的锁资源,其实就是对象。
  • 本质是基于对象在堆内存里的对象头里的MarkWord去控制锁资源的信息,而且所谓的偏向锁,轻量级锁,重量级锁都在Markword中去维护的。image.png
  • 到了重量级锁的时候,他的锁资源的获取等等操作就是基于C++层面的代码去实现的了。在C++里面可以清晰的看到ObjectMonitor。
  • 非要再细聊,就聊ObjectMonitor里内点属性image.png

锁升级:

无锁可以不提,更多的是这哥三:

  • 偏向锁: synchronized会偏向某一个线程,当这个线程多次来拿锁时,会直接放。相当于先比较偏向的是否是当前线程,如果是,直接拿锁资源走人,不是,CAS尝试修改偏向你自己。
    • 一般偏向锁很少会切换偏向的线程,一般都是直接升级到轻量级锁
    • 其次,偏向锁在synchronized中很尴尬,偏向锁的特点是始终一个线程来,能体现他的优势,但是你用synchronized是为了避免多个线程过来。。。。。 So,偏向锁这东西因为他偏向锁撤销,和批量撤销等操作代价很高,需要安全点,拖慢你的性能。 偏向在JDK15的时候,彻底移除。
    • 偏向锁他在并发操作较多时,其实不是正向的优化,他甚至会影响性能。。。。
  • 轻量级锁: 在锁竞争没有那么强的时候,会通过轻量级锁,执行几次CAS操作,如果能成功拿到锁资源就在这个level不动。CAS的次数有自适应自旋锁的东西,你可以理解为,如果上次CAS几次成功了,会追加CAS的次数,如果上次CAS失败了,会减少CAS的次数,甚至直接升级为重量级锁。
  • 重量级锁: 到了重量级锁之后,如果拿不到锁资源,线程会挂起。但是不是一次CAS拿不到就挂起,他也会经历几次CAS的操作,实在拿不到,才会挂起线程。

说说AQS

AQS是AbstractQueneSynchronized的缩写,是JUC并发编程的基础类,其主要提供了state状态,该状态为0时表示没有锁占用,大于0时则有锁在占用。

简单的介绍一下AQS:AQS(AbstractQueuedSynchronizer)本质是JUC包下的一个基础类,他是提供给很多并发的工具,锁之类的内容去继承的,他内部主要维护了三个核心内容,以锁的维度来说的话:

  • state属性: state属性是代表锁资源,如果为0,代表锁资源没线程持有,如果大于0,就代表由某个线程持有了。 所谓的竞争锁资源,其实就是基于CAS,尝试将state从0改为1,谁改成功了,谁就拿到锁资源了。
  • 同步队列(双向链表): 如果前面竞争锁资源失败的线程,会被封装为Node对象,扔到这个同步队列中排队,等待再次尝试获取锁资源。等到持有锁的线程释放锁资源后,会在同步队列中找到最靠前的Node唤醒。
  • 单向链表: 单向链表只有锁中才会用到,他是实现类似synchronized中的wait,notify的功能,在lock锁中,他是await跟signal,也就是持有锁时,可以挂起线程,被挂起的线程会被封装为Node,跟同步队列的Node是一个Node,放到单向链表中,等待被唤醒,唤醒时,会在单向链表找到最靠前的唤醒。 实现线程之间的通讯。

什么是公平锁,什么是非公平锁

公平锁保证顺序但性能较低,非公平锁允许插队但吞吐量更高,Java 默认使用非公平锁是为了减少线程切换、提升性能。

无论公平锁还是非公平锁,最终都会进入 AQS 的同步队列,本质区别只是“是否允许新线程在入队前抢锁”。

公平锁

ReentrantLock lock = new ReentrantLock(true);

非公平锁

ReentrantLock lock = new ReentrantLock();

说说lock锁,它底层是怎么加锁的,进队过程,唤醒过程

当调用.lock方法时,底层会调用tryAxxx的一个方法进行尝试加锁,如果加锁成功即结束,如果没有成功会判断是否可重入,如果可重入即结束,没有则进行下一步,下一步我忘了。

线程在lock加锁后,执行lock方法之后,会走这几个流程

  • 流程1:(lock)
    • 非公平锁:直接基于CAS,尝试将state从0改为1,如果成功直接告辞,失败的话,会走后续逻辑
    • 公平锁:直接走后续逻辑
  • 流程2:(tryAcquire)
    • 非公平锁:
      • 判断锁资源是否被持有,如果没有被持有,再次基于CAS,尝试将state从0改为1,如果成功直接告辞
      • 如果失败会查看是否是锁重入逻辑,如果是,锁重入。
      • 如果不是,直接走后续逻辑。
    • 公平锁:
      • 判断锁资源 是否被持有 ,如果没有被持有,再次判断 是否有正在排队的 线程在同步队列,如果没有排队的,基于CAS,尝试将state从0改为1,如果成功直接告辞
      • 如果锁资源被持有,查看是否是锁重入逻辑,如果是,锁重入。
      • 如果不是,直接走后续逻辑。
  • 流程3:(addWaiter)
    • 到这,代表没拿到锁资源,将当前线程封装为Node对象,扔到同步队列中排队,扔末尾。
  • 流程4:(acquireQueued)
    • 到这会做两个事情
      • 将当前线程挂起,park方法挂起,等待持有锁资源的线程,在释放锁之后,唤醒我。
      • 查看锁资源 是否被持有,并且查看自己是否排在“第一名”,如果满足,走tryAcquire

唤醒过程:持有锁资源的线程,执行了unlock方法,释放锁,在锁资源释放干净后,优先看head.next节点是否正常,不正常才会在同步队列中 从后往前 找到里head节点最近的有效节点,将其唤醒。唤醒的方法就是unpark。

为啥是从后往前,不是从前往后,因为AQS中的同步队列,存在一些取消的操作,而取消后的Node会将他的next指向自己,node.next = node;。 AQS中,next指针并不准确,他以prev指针为准。

volatile去拉的内存是什么内存

主内存,即JVM占用的内存,Java中的数据都是直接写在CPU中的,再由CPU向JVM写入。

所谓的去主内存拉取数据,指的其实是JVM内存中的堆。

是谁去拉取主内存的数据?

因为可见性是因为CPU的多个核心之间,对同一个数据出现了一些不一致的问题。

是CPU去主内存拉取最新的数据,放到CPU的高速缓存中

image.png

撇去volatile,CPU本身也要保证数据的一致性?怎么保证的?

CPU本身就具备多个高速缓存之间一致性的协议,一般CPU都是基于MESI协议去玩的。

E(exclusive):独占,这个数据只有当前一个CPU缓存了,其他CPU没碰,数据安全。

S(shared):共享,这个数据被多个CPU缓存了,但是数据都一样,没安全问题。

M(modify):修改,这个数据被当前的CPU修改了,当前CPU知道,没安全问题。

I(invalid):失效,当前数据被其他位置修改了,我当前CPU不知道,这个数据无效,不能用。

其实,CPU本身就可以基于MESI协议来保证多个CPU之间的高速缓存数据是一致的。

为什么有了MESI协议,还需要volatile关键字?

但是,CPU的目的是为了让处理速度越来越快,不断的升级,前面这种一致性问题,明显会影响到CPU的性能。所以,CPU在迭代升级的时候,有很多缓存机制,极可能让MESI触发别那么频繁。

  • 比如,大部分CPU都有的StoreBuffer,介于CPU跟L1缓存时间,CPU修改完的数据,优先落到SB上,不一定会及时的落到L1中,就导致了MESI协议触发不及时。
  • 再比如Invalidate Queue,这种队列不需要得到一个及时的resp,发出去,广播了,完事,其他的CPU核心不一定会及时处理Invalidate Queue。

volatile就能起到作用了,他在JMM的转换下,针对不同的CPU,会转换成不同的指令,比如×86架构的CPU,会转换为 lock前缀指令 ,这个指令会强制触发MESI协议,同步数据。

volatile能保证原子性吗

volatile只能保证可见性

聊聊线程池、线程池哪用了、线程池的作用、执行时间比较长的任务怎么优化

实际业务中,我们有买量撞库的需求,比如接了微博的流量,我们下游有50多家撞库渠道,需要同时去撞库,我使用了CompletableFuture进行实现,自定义了线程池,对执行时间比较长的还自定义了拒绝策略,为了流量最大化的利用,当走了拒绝策略时我们会返回一中间状态,即允许用户重复撞库。

自定义线程池的参数配置,线上服务器是4核8G的,由于是I/O密集型,最开始先调了核心线程数16,最大线程数也是16(避免线程频繁的创建销毁),工作队列设置的是100,拒绝策略使用自定义的。返回中间态。

继续问,那你们通过俩家就算通过,如何实现俩家通过后就不继续执行其他线程了

实际上我当时考虑过这种情况,但是当时业务情况是撞库通过需要展示出所有撞库通过的协议,需要通过该接口缓存起来等。 如果就想俩家通过后就不继续执行了,可以维护一个AtomicInteger统计成功数,达到阈值后立刻返回成功,并使用Future.cancel(true)取消未完成的其他任务。 Future.cancel(true) 不是强杀线程,只是发中断信号,所以还要配合底层 RPC 超时和任务内部中断检查,才能真正把多余请求尽量收敛掉。

一个重要线程任务如何优先执行,比如上面的业务场景,新来了个线程就要优先执行

如果只是希望高优先级任务在等待队列中尽快执行,可以把 ThreadPoolExecutor 的工作队列改成 PriorityBlockingQueue,让任务带优先级,并按优先级排序。这样核心线程都忙的时候,重要任务进入队列后会排到前面,等线程一空出来就优先执行。

但要注意,这种方式只能解决“排队优先”,不能抢占已经在运行的任务,而且 PriorityBlockingQueue 是无界队列,会导致线程池很难扩到最大线程数,线上使用要谨慎。

所以在真正重要的业务里,我更倾向于做资源隔离:把核心任务放到独立线程池,普通任务放到普通线程池,必要时再配合优先级队列或入口限流。这样即使普通任务把线程池打满,也不会影响核心任务的时效性。维护两个线程池。

我就是想让这个任务马上执行,怎么办?

如果线程已经都在执行任务了,Java 线程池本身不支持任务抢占,不能把正在运行的普通任务挂起去先跑高优任务。 所以要让重要任务真正“马上执行”,只能靠两种方式: 第一,预留独立线程池或专属线程; 第二,在普通任务入口做限流、降级,避免把执行资源完全耗尽。 本质上这不是简单的队列排序问题,而是资源隔离问题。还是维护两个线程池靠谱。

demo

// 普通线程池
private final ExecutorService normalUnionAccessExecutor = new ThreadPoolExecutor(
        10, 20, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(200)
);

// vip线程池
private final ExecutorService vipUnionAccessExecutor = new ThreadPoolExecutor(
        5, 10, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(50)
);

// 通过一些逻辑判断,哪些走普通,哪些走vip
ExecutorService executor = isVipRequest ? vipUnionAccessExecutor : normalUnionAccessExecutor;
CompletableFuture.supplyAsync(() -> doAccess(channel), executor);

为什么需要线程池(京东物流)

在 Java 中,如果每次执行任务都创建一个线程,会有很大的开销。 线程的创建和销毁都需要操作系统参与,频繁创建线程会导致:

  • CPU资源浪费
  • 线程切换开销大
  • 系统容易被线程耗尽

所以 Java 提供了 线程池(ThreadPool) 来复用线程,提高并发处理能力。

线程池的核心参数(京东物流)

  • 核心线程数
  • 最大线程数
  • 线程空闲时间
  • 队列
  • 拒绝策略

线程池的执行流程(京东物流)

会先判断核心线程数,当小于核心线程数时,直接创建线程执行,否则先判断队列长度,如果队列中有位置,则加入队列中,否则判断最大线程数,小于则创建线程执行,大于则拒绝。

execute方法的一些流程点。

核心就这四点:

image.png

核心线程和临时线程的区别是什么?

  • 线程池中,被创建出来的线程,他是不区分核心与临时的。对线程池来说,都是工作线程。
  • 核心与临时只有在创建的时候,他会判断不同的参数,核心线程基于corePoolSize判断,临时线程基于maximumPoolSize判断。
  • 线程池没有去针对区分核心跟临时,他最后判断的依据就是个数…………

当核心线程设置为0个,任务扔到工作队列后,没线程处理怎么办?

其实这个问题线程池已经考虑到了。在任务扔到工作队列中之后,他会额外判断以下,是否存在没有工作线程的情况,如果存在,会直接创建一个非核心线程去处理任务。

线程池为啥不先创建非核心,在丢队列?

  • 这个回答方式,仅限老郑,仅限个人,免责声明!!! 在我看来,临时线程的设计就没有太大的意义,因为在核心线程个数已经经过压测得到一个最好的数值之后,额外的追加线程个数,反而会让任务的吞吐量下降,所以,咱们再设置线程参数时,就应当将很线程个最大线程设置为一样的!如果让我优化,我就直接把所谓的非核心的业务去掉!!!
  • 官方一些的回答! 毕竟线程要占用资源的,如果都是核心线程,对于内存的占用会比较多,在任务体量特别大的时候,可以额外创建几个线程,去处理任务包括工作队列的任务,这样依赖,临时占用内存资源,处理完毕任务后,就可以销毁掉,节约内存资源。(销毁掉的,不一定是临时线程)

创建临时线程时,他会优先处理刚刚投递过来的任务,而不是优先处理在工作队列中的任务,为啥?

  • 源码中自然就是任务直接交给临时线程去处理
    • 这种方式处理速度自然比较快。
    • 如果是优先处理队列的任务,你需要经过队列拉取任务,再将当前新任务投递到工作队列,每个步骤都可能存在并发,并且耗时比较长。(如果此时核心线程正好执行完从队列中拿走了,非核心线程却拿不到了,这是你再存入队列中时,又巧了队列此时又满了,导致死循环了)
    • 而且线程池本身就无法保证任务的顺序执行,没有必要优先处理队列中的任务。

哪为啥不是来了任务直接创建非核心线程去处理,线程数到了最大值才塞队列?

  • 池化技术直接扔了,先创建临时,反而会频繁的创建销毁线程,给系统必须来点损耗~~~不推荐。

守护线程,守护线程如何监控线程池(京东物流)

守护线程是 JVM 中的一种后台线程,当 JVM 中只剩守护线程时 JVM 会退出,例如 GC 线程就是守护线程。

在线程池场景中,JDK线程池本身没有专门的守护线程进行管理,它主要通过 Worker 线程和 ctl 状态变量来控制线程池状态。

但在实际项目中,我们可以通过启动一个守护线程定期监控线程池状态,比如每隔几秒打印线程池的 poolSize、activeCount、queueSize 等指标,用于排查线程池是否出现任务堆积或者线程耗尽的问题。

在生产环境中通常会结合监控系统,比如 Prometheus 或 Spring Boot Actuator 来采集这些指标。

线程池中的任务失败的后续处理。

当线程池处理某个任务出现了异常之后,有什么操作?

  • execute提交的任务,执行任务时,出现了异常,那就没了,你可以自己在任务的业务中追加一些判断,但是线程池不会做什么事。而且出现的这个异常会导致当前的工作线程被干掉,run方法异常结束。
  • submit提交的任务,他会将任务封装成FutureTask,而FutureTask不会将处理的任务向上抛出,而是将任务存在FutureTask内部。任务结束后续的处理你可以基于返回的FutureTask直接get到,同时处理这个任务的线程不会被干掉,因为异常被FutureTask捕获了。

线程池怎么优雅宕机、关闭。

shutdown

线程池场景,核心线程数0,最大线程数2,阻塞队列用Linked,长度是8,过期时间2s,循环8次,在循环里面提交任务,然后打印循环下标。执行完顺序是什么?一开始就会创建2个线程吗?如果队列改成5个呢?

← 全部文章