# Java面试总结

文章目录

# 资源

网盘:https://pan.baidu.com/s/1NQZDW-9_VCnEUXcXp_G-bA&pwd=9987

# Redis篇

面试问题总览:

黑马Redis面试问题总览

# 问题:我看你做的项目中,都用到了 Redis,你在最近的项目中哪些场景使用了 Redis 呢?

该问题一是验证你的项目场景的真实性,二是为了作为深入发问的切入点。因此,要根据自己简历上的业务进行回答

使用场景及对应深入问题:

使用场景 深入问题
缓存 缓存三兄弟 (穿透、击穿、雪崩) 、双写一致、持久化、数据过期策略,数据淘汰策略
分式锁 setnx、redisson
消息队列、延迟队列 何种数据类型

# ⚪ 问题:什么是缓存穿透、击穿、雪崩?如果发生了缓存穿透、击穿、雪崩,该如何解决?

# ⚪ ❓什么是缓存穿透、击穿、雪崩?

(1)缓存穿透

用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是 缓存穿透 的问题。

(2)缓存击穿

缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被 高并发的请求 冲垮,这就是 缓存击穿 的问题。

(3)缓存雪崩

大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机 时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是 缓存雪崩 的问题

小林Coding-缓存异常总结
# ⚪ ❓怎么解决缓存穿透?

▣ 解决方案1:限制非法请求

在 API 入口处(在访问缓存和数据库之前)判断请求参数是否合理(数据合法性校验),请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

▣ 解决方案2:缓存空值或者默认值

可针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

  • 优点:简单
  • 缺点:消耗内存,可能会发生数据不一致的问题

▣ 解决方案3:布隆过滤器快速判断数据是否存在

在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,说明数据库中不存在该数据,则直接返回,无需查询数据库。

即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行。

  • 优点:内存占用较少,没有多余 key
  • 缺点:实现复杂,存在误判
# ⚪ ❓怎么解决缓存击穿?

▣ 解决方案1:互斥锁

在缓存失效时,需要去访问数据库更新缓存。因此,通过添加互斥锁,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

▣ 解决方案2:不给热点数据设置过期时间 / 逻辑过期

在设置缓存时,给key添加一个过期时间字段。在查询缓存数据时,判断数据的过期时间字段是否过期。

如果过期了,开一个线程由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存,并重新设置过期时间字段。当前请求线程仍然正常返回数据,这个数据不是还最新的。

缓存击穿解决方案:互斥锁&逻辑过期
# ⚪ ❓怎么解决缓存雪崩?

发生缓存雪崩有两个原因:

  1. 大量数据同时过期
  2. Redis 故障宕机

不同的诱因,应对的策略也会不同。

原因1:大量数据同时过期

▣ 解决方案1:均匀设置过期时间

避免将大量的数据设置成同一个过期时间,所以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。

▣ 解决方案2:互斥锁

当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

▣ 解决方案3:后台更新缓存 / 不给热点数据设置过期时间 / 逻辑过期

原因2:Redis 故障宕机

▣ 解决方案1:服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动 服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作,所以要审慎选择该操作。

为了减少对业务的影响,我们可以启用 请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

▣ 解决方案2:构建 Redis 缓存高可靠集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

# ⚪ 问题:Redis做为缓存,Mysql的数据如何与Redis进行同步呢?(双写一致性)

简单回顾数据一致性问题:

引入Redis缓存后,客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用在去查询数据库,从而减轻数据库的压力,提高服务器的性能。

但是引入了缓存,那么在数据更新时,需要同时处理Redis缓存和Mysql数据库中的数据,这其中就涉及到数据一致性的问题。

思路1:数据更新时,同时更新缓存和数据库

  • 策略1:先更新缓存,再更新数据库
  • 策略2:先更新数据库,再更新缓存

这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象

如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为这种策略不会出现缓存未命中的情况。同时增加一些手段来解决数据不一致的问题,这里提供两种做法:

  • 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
  • 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。

思路2:写操作—数据更新时,只更新数据库,删除缓存;读操作—在数据读取时,如果缓存命中,直接返回。如果缓存未命中,则查询数据库数据,写入缓存设置超时时间。

  • 策略1:先删除缓存,再更新数据库
  • 策略2:先更新数据库,再删除缓存

策略1 在「读操作+写操作」并发的时候,还是会出现缓存和数据库的数据不一致的问题。而 策略2 在原子操作下一定程度上可以保证数据一致性。

针对策略1在「读操作+写操作」并发请求而造成缓存不一致的解决办法是「延迟双删」,伪代码如下:

# 删除缓存
redis.delKey(X)
# 更新数据库
db.update(X)
# 睡眠
Thread.sleep(N)
# 再删除缓存
redis.delKey(X)
1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8

策略2在原子操作下可以保证数据一致性,也就是说还是可能出现数据不一致的问题,怎么发生的?其实就是:在删除缓存(第二个操作)的时候失败了,导致缓存还是旧值,而数据库是最新值,造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。

有两种解决方法:

  • 方法1:重试机制—引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
    • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
    • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
先更新数据库,再删除缓存—异步方案—MQ重试机制
  • 方法2:订阅 MySQL binlog,再操作缓存。
先更新数据库,再删除缓存—异步方案—订阅 MySQL binlog

# ⚪ 问题:Redis做为缓存,数据的持久化是怎么做的?

# 🎯Java集合篇

# List相关面试题

# ⚪ ArrayList底层的实现原理❓

# ⚪ ArrayList的扩容机制❓

🙋🏻‍♂️ 答:

扩容核心方法grow(int minCapacity), 参数表示最小需要容量,是为了保证数组长度能够容纳至少 minCapacity 个元素

扩容机制

  • 在调用 add(E e) 或其他添加元素的方法时,内部首先会通过 ensureCapacityInternal() 来保证数组拥有足够空间,然后再添加元素。
  • ensureCapacityInternal() 方法内部就会判断数组是否需要扩容,若需要,就调用 grow() 方法。
  • grow() 内部,ArrayList 每次扩容之后的容量都会变为原来的 1.5 倍左右(int newCapacity = oldCapacity + (oldCapacity >> 1), oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)

扩容示例(以无参构造实例化ArrayList为例):

  • 无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组(this.elementData = {})。
  • 当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0 成立,所以会进入 grow(minCapacity) 方法。
  • add 第 2 个元素时,minCapacity2,此时 elementData.length(容量) 在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。
  • 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。
  • 直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容,数组扩容成 15。

# ⚪ ArrayList和Array(数组)的区别❓

🙋🏻‍♂️ 答:

ArrayList 内部基于动态数组实现,比 Array(静态数组) 使用起来更加灵活。

两者的区别可以从以下这几方面回答:

  • 扩容机制ArrayList 会根据实际存储的元素动态地扩容或缩容,而 Array 被创建之后就不能改变它的长度了。
  • 泛型支持ArrayList 允许你使用泛型来确保类型安全,Array 则不可以。
  • 存储类型ArrayList 中只能存储对象。对于基本类型数据,需要使用其对应的包装类(如 Integer、Double 等)。Array 可以直接存储基本类型数据,也可以存储对象。
  • 操作能力ArrayList 支持插入、删除、遍历等常见操作,并且提供了丰富的 API 操作方法,比如 add()remove() 等。Array 只是一个固定长度的数组,只能按照下标访问其中的元素,不具备动态添加、删除元素的能力。
  • 初始化ArrayList创建时不需要指定大小,而 Array 创建时必须指定大小。

# ⚪ 如何实现Array(数组)和List之间的转换❓

🙋🏻‍♂️ 答:

(1)两个方法

  • 数组转List ,使用JDK中 java.util.Arrays 工具类的 asList 方法
    • 使用List的修改方法: add()remove()clear() 会抛出异常
    • 通过几种方法可以解决上条无法修改的问题,比如:
      • 方法1:List list = new ArrayList<>(Arrays.asList("a", "b", "c"))
      • 方法2:使用Java8的 Stream(推荐)
      Integer [] myArray = { 1, 2, 3 };
      List myList = Arrays.stream(myArray).collect(Collectors.toList());
      //基本类型也可以实现转换(依赖boxed的装箱操作)
      int [] myArray2 = { 1, 2, 3 };
      List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList());
      
      1
      2
      3
      4
      5
      1
      2
      3
      4
      5
      • 方法3:使用Java9的 List.of() 方法
      Integer[] array = {1, 2, 3};
      List<Integer> list = List.of(array);
      
      1
      2
      1
      2
  • List转数组,使用 ListtoArray 方法。
    • 无参 toArray 方法返回 Object 数组
    • 传入数组对象,返回该对象数组

(2)代码示例

  • 数组转List


     



     

    // int数组,需要使用基本类型对应的包装类来创建数组
    Integer[] a1 = new Integer[]{0, 1};
    List<Integer> b1 = Arrays.asList(a);
    
    // String数组
    String[] a2 = new String[]{"aaa", "bbb", "cccc"};
    List<String> b2 = Arrays.asList(a);
    
    1
    2
    3
    4
    5
    6
    7
    1
    2
    3
    4
    5
    6
    7
  • List转数组


     



     

    List<Integer> b1 = new ArrayList<>();  // LinkedList也可以
    b1.add(0);  b1.add(1);
    Integer[] a1 = b1.toArray(new Integer[0]);
    
    String[] b2 = new LinkedList<>();
    b2.add("aaa");  b2.add("bbb");  b2.add("ccc");  
    List<String> a2 = b2.toArray(new String[0]);  // new String[0]就是起一个模板的作用
    
    1
    2
    3
    4
    5
    6
    7
    1
    2
    3
    4
    5
    6
    7

拓展问题

🧛🏻‍♂️ 问:用 Arrays.asList 将数组转 List 后,如果修改了数组内容,List 受影响吗?

🙋🏻‍♂️ 答:Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址

🧛🏻‍♂️ 问:List 用 toArray 转数组后,如果修改了 List 内容,数组受影响吗?

🙋🏻‍♂️ 答:list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

# ⚪ ArrayList和LinkedList的区别❓

🙋🏻‍♂️ 答:

可以从以下几方面回答:

  • 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
  • 插入和删除是否受元素位置的影响
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行 add(E e) 方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)addFirst(E e)addLast(E e)removeFirst()removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)remove(Object o), remove(int index)), 时间复杂度为 O(n)O(n) ,因为需要先移动到指定位置再插入和删除。
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于 get(int index) 方法)。
  • 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

阅读 RandomAccess 接口中的源码,发现其中什么都没有定义,所以其作用是标识实现这个接口的类具有随机访问功能

# ⚪ ArrayDeque 与 LinkedList 的区别❓

ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

🙋🏻‍♂️ 答:

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
  • ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。

# 说一说 PriorityQueue

🙋🏻‍♂️ 答:

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

  • 底层PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • 调整PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • 元素PriorityQueue非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
  • 初始化PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

注:PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。

# HashMap相关面试题

# ⚪ HashMap的put方法具体流程❓

🙋🏻‍♂️ 答:

HashMap 的 put(key, value) 添加方法:

  1. 计算 keyhash 值(通过 hash(key) 计算):
    • JDK 1.7:
      • (1) h ^= (h >>> 20) ^ (h >>> 12);
      • (2) h ^ (h >>> 7) ^ (h >>> 4);
    • JDK 1.8:((h = key.hashCode()) ^ (h >>> 16))
  2. 定位 key 存储位置 bucket(桶) 下标:index = hash & (table.length - 1)
  3. 插入操作,分情况:
    • 情况1 bucket 为空桶:直接插入
    • 情况2 bucket 非空桶:快速判断第一个节点是否与插入的 key 相同,相同则插入,不同则继续...(JDK 1.8 要先判断是链表还是红黑树,再接着遍历)
      • key 不存在:将键值存储到链表/红黑树尾部;(Java7 是插入到链表的最前面/头插法)
      • key 存在:遍历链表/红黑树比较 key,如果存在 key,更新 value
  4. 插入/更新元素之后,判断当前 bucket 链表是否需要升级为红黑树:链表长度大于 8 ,并且数组长度大于 64,则升级为红黑树(treeifyBin 方法中判断是否真的转换为红黑树);
  5. 扩容判断:++size > threshold 成立,则进行扩容操作 resize();(Java7 是先扩容后插入新值的,Java8 先插值再扩容)
  6. 将执行结果返回

# ⚪ HashMap的寻址算法❓

🙋🏻‍♂️ 答:

  • Step1 / 首先,通过 hash(key) 得到对应 key 的哈希值 hash
    • keynull 时,hash0
    • key 不为 null 时,计算 (h = key.hashCode()) ^ (h >>> 16),即将 key.hashCode() 的 高16位 和 低16位 进行 异或运算,运算结果作为哈希值 hash
    • hash(key) 相当于对原始哈希码做了扰动,使得扰动后的新哈希码 hash 更加均匀,减少哈希冲突
  • Step2 / 然后,通过 hash & (n - 1) 计算 key 在哈希表(数组)中的 bucket(桶) 索引
    • n 表示哈希表(数组)长度
    • 该计算代替常规的取模运算,性能更好。前提是:要求 n 必须是 2 的次幂

# ⚪ 讲一讲HashMap的扩容机制❓

🙋🏻‍♂️ 答:

关键点:扩容入口 put() 、扩容函数 resize() 、惰性加载、容量翻倍、数据迁移

内容

HashMap 是惰性加载,在创建对象 new HashMap<>() 时并没有初始化数组。

因此,在首次使用 HashMap 实例的 put 操作时,会调用 resize() 方法进行扩容:

  • 如果是无参构造,那么初始化数组长度为16;
  • 如果提供了参数 initialCapacity,那么初始化数组长度是与 initialCapacity 最接近的 2 的幂次方大小(HashMap 中的 tableSizeFor(initialCapacity) 方法保证);

put 操作之后,都会检查 ++size > threshold 是否成立(size 表示 HashMap 已存放的元素数量,threshold 表示容量阈值)。如果成立,表示需要扩容,数组扩大到之前容量的 2 倍 newThr = oldThr << 1

resize() 中,扩容操作是创建一个新的数组,所以在扩容后需要把老数组中的数据挪动到新数组中:

  • 如果是没有哈希冲突的节点,直接计算节点新的位置即可 e.hash & (newCap - 1)
  • 如果是红黑树,则走红黑树的添加
  • 如果是链表,则遍历链表每个节点,通过 hash & oldCap == 0 判断节点是否需要换位置,true 表示留在原位置 jfalse 表示移动到新位置 j + oldCap

# ⚪ 为何HashMap的数组长度一定是2的次幂?❓

🙋🏻‍♂️ 答:

  1. 计算索引时效率更高:如果是 2 的 n 次幂,可以使用位与运算代替取模运算;
  2. 扩容时重新计算索引效率更高:扩容时,newCapoldCap 的 2倍,重新计算某个 bucket oldTab[j] 中的链表结点的下标时,满足 hash & oldCap == 0 的元素留在原来位置 j,即 newTab[j],否则该元素的新位置为 j+oldCap,即 newTab[j + oldCap]

# ⚪ HashMap在JDK1.7下的多线程操作导致死循环问题❓

🙋🏻‍♂️ 答:

JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得后续的查询元素操作陷入遍历死循环无法结束。

为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。

⚠ 注:不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。

# ⚪ HashMap和HashSet的区别❓

🙋🏻‍♂️ 答:

HashSet 里面有一个 HashMap(适配器模式),所以可以说:HashSet 底层就是基于 HashMap 实现的。

HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

以下为 HashMap 部分源码:

//HashSet是对HashMap的简单包装
public class HashSet<E>
{
	//......

	private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    public HashSet() {
        map = new HashMap<>();
    }

    //......

    public boolean add(E e) {//简单的方法转换
        return map.put(e, PRESENT)==null;
    }

    //......
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# ⚪ HashMap为什么线程不安全❓

🙋🏻‍♂️ 答:

JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成 死循环数据丢失 的问题。

数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在 ,这里以 JDK 1.8 为例进行介绍。

JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程 对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险

举个例子:

  • 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
  • 不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
  • 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。

还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:

  • 线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。
  • 线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。
  • 随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
  • 线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。

# 并发编程篇

黑马并发面试问题总览

# 线程的基本知识

# ⚪ 线程和进程的区别❓

🙋🏻‍♂️ 答:

(1)概念

  • 进程(Process) 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。
  • 线程(Thread) 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源,比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。

在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。

(2)区别

  • 基本单位:进程是操作系统分配资源的基本单位;线程是程序执行(CPU调度)的基本单位
  • 包含关系:一个进程可以包含一个或多个线程;线程不能包含进程;
  • 资源占有:进程拥有一个完整的资源平台(独立的内存空间和资源);线程只独享必不可少的资源(如寄存器和栈),共享进程的内存和资源;
  • 状态:进程和线程都具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 开销:线程能减少并发执行的时间和空间开销(进程切换的成本比较大;线程切换成本比较小);

对于“线程相比进程能减少开销”,体现在:

  • 创建开销:线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 销毁开销:线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 切换开销:同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 交互开销:由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

# ⚪ 并行和并发的区别❓

🙋🏻‍♂️ 答:

(1)概念不同

  • 并发(concurrent):同一时间应对(dealing with)多件事情 / 两个及两个以上的作业在 同一时间段 内执行。
  • 并行(parallel):同一时间动手做(doing)多件事情 / 两个及两个以上的作业在 同一时刻 执行。

最关键的点是:是否是同时执行。

(2)在多核 CPU 下

  • 并发:多个线程轮流使用一个CPU
  • 并行:4个CPU核同时执行4个线程

# ⚪ 创建线程的方式有哪些❓

🙋🏻‍♂️ 答:

Java 中创建线程的三种标准方式

  1. 继承 Thread 类,重写 run() 方法
// 1) 继承 `Thread` 类
public class MyThread extends Thread {
    // 2) 重写 `run()` 方法
    @Override
    public void run() {
        // ...
    }
}
1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8
class Main {
    public static void main(String[] args) {
        MyThread instance = new MyThread();  // 3) 创建线程对象
        instance.start();
    }
}
1
2
3
4
5
6
1
2
3
4
5
6
  1. 实现 Runnable 接口,重写 run() 方法
// 1) 实现 `Runnable` 接口
public class MyRunnable implements Runnable {
    // 2) 重写 `run()` 方法
    @Override
    public void run() {
        // ...
    }
}
1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8
class Main {
    public static void main(String[] args) {
        MyRunnable instance = new MyRunnable();  // 3) 创建线程对象
        Thread thread = new Thread(instance);
        thread.start();
    }
}
1
2
3
4
5
6
7
1
2
3
4
5
6
7
  1. (前两种方式都不能拿到线程的返回值) 实现 Callable 接口,重写 call() 方法,返回值通过 FutureTask 进行封装
// 1) 实现 `Callable` 接口,返回值为 Integer
public class MyCallable implements Callable<Integer> {
    // 2) 重写 `call()` 方法
    @Override
    public Integer call() throws Exception {
        // ...
        return 123;
    }
}
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
class Main {
    public static void main(String[] args) {
        MyCallable instance = new MyCallable();  // 3) 创建线程对象
        FutureTask<Integer> ft = new FutureTask<>(instance);
        Thread thread = new Thread(ft);
        thread.start();
        System.out.println(ft.get());
    }
}
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9

还有两种有歧义的方式(不建议回答,但可以了解,并明白它们的本质):

  1. Lambda表达式实现 Runnable 接口的方法
  2. 使用线程池创建:
    • 1)通过 ThreadPoolExecutor 构造函数创建(✅推荐)
      • new ThreadPoolExecutor(...);
    • 2)通过工具类 Executors 来创建(❎不推荐)
      • Executors.newCachedThreadPool(...);
      • Executors.newFixedThreadPool(...);
      • Executors.newSingleThreadExecutor(...);
      • Executors.newScheduledThreadPool(...);

# ⚪ 使用Runnable和Callable都可以创建线程,它们有什么区别❓

🙋🏻‍♂️ 答:

  1. 定义和返回值:
    • Runnable 接口定义的是 没有返回值的run()方法
    • Callable 接口定义了的是 有返回值的 call() 方法
  2. 异常处理:
    • Runnable 接口的 run() 方法不能抛出异常(受检查异常),只能在方法内部进行异常处理
    • Callable 接口的 call() 方法可以抛出异常,调用者需要进行相应的异常处理
  3. 返回结果:
    • Runnable 接口的 run() 方法没有返回值,无法获取任务的执行结果
    • Callable 接口的 call() 方法有返回值(泛型类型),配合 FutureFutureTask 获取任务的执行结果

# ⚪ 在启动线程的时候,可以使用run方法吗?run()和start()有什么区别❓

🙋🏻‍♂️ 答:

(1)直接回答

可以直接执行 run() 方法,但是,这种方式会把 run() 方法当成 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作

(2)分析

new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

(3)区别

作用:

  • start(): 启动一个线程并使线程进入了就绪状态,通过该线程调用 run() 方法,这是真正的多线程工作。
  • run(): 封装了要被线程执行的代码。

调用次数:

  • start(): 只能被调用一次
  • run(): 可以被调用多次

🧛🏻‍♂️ 引申的两个问题

  • 反复调用同一个线程的 start() 方法是否可行?
  • 假如一个线程执行完毕(此时处于 TERMINATED 状态),再次调用这个线程的 start() 方法是否可行?

🙋🏻‍♂️:都不行,在调用 start() 之后,threadStatus 的值会改变(threadStatus !=0),再次调用 start() 方法会抛出 IllegalThreadStateException 异常。

# ⚪ 线程包括哪些状态,状态之间是如何变化❓

🙋🏻‍♂️ 答:

(1)线程的生命周期和状态

Java线程一个有6种不同的状态,如下:

// Thread.State 源码 —— 线程状态的枚举类
public enum State {
    // 新建     —— 线程被创建出来,但没有被调用 start()
    NEW,            
    // 可运行   —— 线程被调用了 start() 等待运行的状态
    RUNNABLE, 
    // 阻塞     —— 需要等待锁释放
    BLOCKED,        
    // 无限等待 —— 处于该状态的线程需要等待其他线程做出一些特定动作
    WAITING,        
    // 限期等待 —— 指定时间后自动返回 RUNNABLE
    TIMED_WAITING,  
    // 终止     —— 表示该线程已运行完毕
    TERMINATED;     
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

线程的生命周期并不是固定处于某一个状态,而是随着代码的执行在不同状态之间切换。

(2)线程状态之间是如何变化?

如图所示:

Java 线程状态变迁图
  • new Thread() 线程创建之后它将处于 NEW 状态,调用 start() 方法后开始运行,线程这时候处于 RUNNABLE 状态
  • 当线程执行 wait() 方法之后,线程进入 WAITING状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态
  • TIMED_WAITING 状态相当于在等待状态的基础上增加了超时限制。当超时时间结束后,线程将会返回到 RUNNABLE 状态
  • 当线程进入 synchronized 方法/块或者调用 wait() 后(被 notify())重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候(等待锁)线程就会进入 BLOCKED 状态
  • 线程在执行完了 run() 方法之后将会进入到 TERMINATED 状态

# ⚪ wait()和sleep()方法的不同❓

🙋🏻‍♂️ 答:

相同点:两者都是让当前线程暂停执行

不同点

  • 所属类不同sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
  • 锁特性不同sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • 作用场景不同
    • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行
    • 调用 wait() 的前提是该线程必须持有某个锁,sleep() 可以在任何地方调用
  • 唤醒对象不同wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。

# ⚪ 如何停止一个正在运行的线程❓

🙋🏻‍♂️ 答:

有三种方式可以停止线程:

  • 方式1:使用退出标志,使线程正常退出,也就是 run() 方法完成后线程终止
  • 方式2:使用stop方法强行终止。(不推荐,方法已作废,这个方法会导致一些清理性的工作得不到完成,如文件,数据库等的关闭,以及数据不一致的问题)
  • 方式3:使用 interrupt() 方法中断线程
    • 打断阻塞( sleep,wait,join )的线程,线程会抛出InterruptedException异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

详细参考:

  • https://www.bilibili.com/video/BV13P411b7gw
  • https://baijiahao.baidu.com/s?id=1771200491206091094&wfr=spider&for=pc

# 线程中的并发安全

# ⚪ synchronized关键字的底层原理❓

🙋🏻‍♂️ 答:

(1)底层

底层由 monitor 实现,C++ (JVM 底层通过 C++ 实现) 通过 ObjectMonitor 实现了 synchronized

具体来说,每个锁对象都有一个关联的 monitor(监视器),其原理是:锁对象的对象头的 MarkWord (32bit) 中, MarkWord 的 ptr_to_heavyweight_monitor 指针指向 ObjectMonitor 对象

ObjectMonitor 内部有四个属性,分别是 _count_owner_EntryList_WaitSet

  • _count: 计数器,如果不为0则表示当前 ObjectMonitor 对象已被持有,通过计数器实现可重入锁
  • _owner: _owner 关联的是当前持有锁的线程(仅有一个),指向当前持有 ObjectMonitor 对象的线程
  • _EntryList: _EntryList 关联的是处于 BLOCKED 状态的线程。每个阻塞等待获取锁的线程都会被封装成 ObjectWaiter 对象并进入这里
  • _WaitSet: _WaitSet 关联的是处于 WAITING 状态的线程。调用了 wait() 方法后,线程就会进入到这里

(2)加锁和释放锁

synchronized 采用互斥的方式让同一时刻至多只有一个线程能持有锁对象。加了 synchronized 关键字的地方,可以保证在任意时刻,只有一个线程能执行该方法或代码块。

性质:悲观锁、可重入锁

  • 加锁:Monitorenter 指令,使锁对象的计数器+1,如果计数器从0变成1,说明执行该指令的线程成功持有锁
  • 释放锁:Monitorexit 指令,使锁对象的计数器-1,如果计数器从1变成0,说明执行该指令的线程成功释放锁

(3)可重入原理

synchronized 可重入原理是通过 JVM 内部维护一个锁对象(锁)的计数器来实现的。

首先,可重入性是针对同一个线程(锁程)多次获取同一把「锁对象」的情况。每个「锁对象」都有一个关联的 monitor(监视器),monitor 里包含了一个计数器。

当线程尝试进入 synchronized 修饰的同步代码块/方法/..时,会先去查看 synchronized 「锁对象」的计数器:

  1. 情况1:如果计数器为 0,表示无锁。
    • 当线程会立即获取到「锁对象」,也与对应的 monitor 和计数器产生关联,计数器 + 1;
    • 其他线程不能再获取「锁对象」,进入同步队列(SynchronizedQueue)等待。
  2. 情况2:如果计数器不为 0,并且该线程已经关联了该「锁对象」的 monitor,说明已经拿到了锁对象的所有权。
    • 则表示该线程重入了这把锁,计数器 +1;
    • 随着重入的次数,计数器一直累加。
    • 相反的,退出同步代码块时,计数器 -1,直到计数器为 0 时,表示该线程释放了锁。
  3. 情况3:如果计数器不为 0,而该线程并没有关联 monitor,说明锁被别的线程持有,该线程需要等待锁的释放。

(4)锁升级

锁升级细化流程

# ⚪ 谈谈 JMM(Java内存模型)❓

# ⚪ 谈谈 CAS,另外什么是悲观锁和乐观锁❓

🙋🏻‍♂️ 答:

(1)悲观锁和乐观锁

  • 悲观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,其他线程想拿到这个资源就需要(阻塞)等待锁被释放
    • synchronizedReentrantLock 属于悲观锁
  • 乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,所以不会对共享资源上锁,获取资源操作也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改
    • 一般会使用版本号机制或 CAS 算法实现乐观锁
    • java.util.concurrent.atomic 包下面的原子变量类(比如 AtomicIntegerLongAdder)就是使用了 CAS 实现的

适用场景

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考 java.util.concurrent.atomic 包下面的原子变量类)。

(2)CAS — 乐观锁的一种实现方式

CAS 全称 Compare And Swap(比较再交换),用于实现乐观锁。CAS 的思想很简单,就是用一个预期值 E 和要更新的变量值 V 进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

具体来说,CAS 操作涉及三个操作数:

  • V : Var. 要修改的变量的内存位置
  • E : Expected. 预期旧值
  • N : New. 拟写入的新值

当想要对一个变量执行 CAS 时,其执行流程

  1. 执行前:记录 VE 预期旧值
  2. 执行时:获取 V 目前的值和 E 预期旧值进行对比(Compare)
    • 如果相等,说明该变量没有被其他线程修改,因此将新值 N 更新到 V 上(Swap);
    • 如果不相等,说明该变量被其他线程修改了,当前线程此次修改失败,允许再次尝试修改,即通过自旋进行下一轮 CAS。
  • 优点:无需对共享资源加锁,线程不会阻塞
  • 缺点/问题
    • ABA 问题
    • 循环时间长开销大:多个线程对共享资源进行修改时,CAS 竞争激烈,导致频繁失败和自旋重试
    • 只能保证一个共享变量的原子操作

Java 语言并没有直接实现 CAS,底层依赖于 sun.misc 包下的 Unsafe 类来直接调用操作系统底层的 CAS 指令。

Unsafe 类提供了 compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong 本地方法,来实现的对 Objectintlong 类型的 CAS 操作,这些本地方法是通过 C++ 内联汇编的形式实现的(JNI 调用)。

# ⚪ 请谈谈你对 volatile 的理解❓

🙋🏻‍♂️ 答:

一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  1. 保证线程间的可见性

volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见

  1. 禁止进行指令重排序

指令重排:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果

总结

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

# ⚪ 请你说一下自己对于 AQS 原理的理解❓

🙋🏻‍♂️ 答:

AQS 全称是 AbstractQueuedSynchronizer,即抽象队列同步器。

AQS是一个用来构建锁和同步器的框架,许多同步类实现都依赖于 AQS,如常用的 ReentrantLock / Semaphore / CountDownLatch 等。

(1)核心思想

最主要的,AQS 维护了一个代表共享资源的 state 属性和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列):

  1. state 共享资源:
    • volatile int state; 随着具体实现的不同,表达的含义也不相同
      • ReentrantLock 为例,初始值为 0 ,表示无锁状态。线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1,因此值大于 0 表示锁定状态。
      • CountDownLatch 以例,初始值为 n,表示 n 个子线程执行。每个子线程执行完后 countDown() 一次,将 state-1,因此最后值为 0 表示执行完毕。
    • 访问 state 有三种方式:getState()setState(int newState)compareAndSetState(int expect, int update)
  2. FIFO 线程等待队列:
    • 将暂时获取不到锁的线程加入到该队列中
    • 该队列由 CLH 队列锁实现,一个虚拟的双向队列(不存在队列实例,仅存在结点之间的关联关系)
    • AQS 是将每条请求共享资源的线程封装成 CLH 锁队列的一个结点(Node)来实现锁的分配
Sync queue

(2)AQS 对资源的共享方式

AQS定义两种资源共享方式:

  • 独占方式 / Exclusive:只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • 共享方式 / Share:多个线程可同时执行,如 SemaphoreCountDownLatch

# ⚪ ReentrantLock的实现原理❓

🙋🏻‍♂️ 答:

(1)内部类

ReentrantLock 主要利用 AQS 来实现公平锁或非公平锁。默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

ReentrantLock 总共有三个内部类:SyncNonfairSyncFairSyncNonfairSyncFairSync 类继承自 Sync 类,Sync 类继承自AbstractQueuedSynchronizer 抽象类 AQS。如图所示:

20240526164006

Sync 类继承自 AQS,并实现了 AQS 提供的两个模板方法 tryRelease(int releases)tryAcquire(int acquires)

(2)核心

ReentrantLock 内有一个 Sync 类型的属性 sync ,我们对 ReentrantLock 的很多操作都转化为对 sync (即 Sync 对象)的操作,由于 Sync 继承了 AQS,所以基本上都可以转化为对 AQS 的操作。

所以可知,在 ReentrantLock 的背后,是AQS对其服务提供了支持。

// 同步队列
private final Sync sync;
// 默认非公平策略
public ReentrantLock() {
    sync = new NonfairSync();
}
// 传递参数确定采用公平策略或者是非公平策略
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

# ⚪ synchronized和Lock有什么区别❓

🙋🏻‍♂️ 答:

区别:

  • 实现不同
    • synchronized 是关键字,源码在 JVM 中,用 C++ 语言实现
    • Lock 是接口,源码由 JDK 提供,用 Java 语言实现
  • 释放锁不同
    • 使用 synchronized 时,退出同步代码块锁会自动释放
    • 使用 Lock 时,需要手动调用 unlock 方法配合 try...finally... 释放锁
  • 锁对象不同
    • synchronized 加锁和释放的时机单一,每个锁仅有一个单一的条件
    • Condition 与 Lock 的结合可以做到加锁与多个条件相关联
  • 可否中断
    • synchronized 试图获取锁的时候不能设定超时,也不能中断一个正在使用锁的线程
    • Lock 可以中断和设置超时(lock.lockInterruptibly()),即正在等待的线程可以选择放弃等待
  • synchronized 无法知道是否成功获得锁。相对而言,Lock 可以拿到状态,如果成功获取锁,....,如果获取失败,.....
  • Lock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁
Synchronized有什么样的缺陷?Java Lock是怎么弥补这些缺陷的?

# ⚪ 死锁产生的条件是什么❓

🙋🏻‍♂️ 答:

死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
—— JavaGuide

死锁指两个以上的运算单元(进程、线程、虚拟线程/协程),都在等待对方释放资源,但没有一方提前释放资源,所造成的阻塞现象就叫做死锁。

产生死锁的四个必要条件

  • 互斥条件:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
  • 占有并等待条件:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
  • 不可剥夺条件:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。
  • 环路等待条件:有一组等待进程 {P0, P1,..., Pn}P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,……,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。

⚠️注意:这四个条件是产生死锁的必要条件,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

# ⚪ 如何进行死锁诊断❓

🙋🏻‍♂️ 答:

(1)诊断

当程序出现了死锁现象,我们可以使用JDK自带的工具:jps 和 jstack

  • jps:输出JVM中运行的进程状态信息
  • jstack:查看Java进程内线程的堆栈信息,查看日志,检查是否有死锁。如果有死锁现象,需要查看具体代码分析后,可修复
  • 可视化工具 jconsole、VisualVM 也可以检查死锁问题

(2)解决死锁

Java中解决死锁的方法:

  1. 打破环路等待条件:顺序锁(获取锁的顺序是一致),按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。
  2. 打破占有并等待条件:
    • 一次性申请所有的资源
    • 使用 ReentrantLocktryLock 方法
  3. 打破不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源

# ⚪ 聊一下ConcurrentHashMap❓

🙋🏻‍♂️ 答:

ConcurrentHashMap 的实现在 JDK1.7 和 JDK 1.8 有很大不同。

  1. 底层数据结构:
    • JDK1.7 采用的数据结构是 Segment 数组 + HashEntry 数组 + 链表
    • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,Node 数组 + 链表 / 红黑树
  2. 加锁的方式
    • JDK1.7 采用 Segment 分段锁机制,底层使用的是 ReentrantLock
    • JDK1.8 采用 CAS 添加新节点,采用 synchronized 锁定链表或红黑二叉树的首节点,相对Segment 分段锁粒度更细,性能更好。(CAS保证添加节点时没有多线程冲突,synchronized 保证访问 Map 时没有多线程冲突

https://zhuanlan.zhihu.com/p/31614308

# ⚪ 导致并发程序出现问题的根本原因是什么❓

换一种问法:Java程序中怎么保证多线程的执行安全?

🙋🏻‍♂️ 答:

并发程序出现问题,其根源是并发三要素/三问题:可见性、原子性、有序性

  • 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到
  • 原子性:即 一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
  • 有序性:程序执行的顺序按照代码的先后顺序执行

在保证这三者生效的情况下,Java程序可以保证多线程的安全执行。若出现并发问题,便是这三者中的其中一个或多个无法得到保证,比如:

  • CPU 缓存 破坏了 可见性
  • 时分复用 破坏了 原子性
  • 重排序(Instruction Reorder) 破坏了 有序性

解决方法

  1. 原子性:synchronizedLock
  2. 可见性:volatilesynchronizedLock
    • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。
    • synchronized 关键字两者都能保证。
  3. 有序性:volatile
    • volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果

# 线程池

# ⚪ 说一下线程池的核心参数(线程池的执行原理知道嘛)❓

🙋🏻‍♂️ 答:

Executors 线程池的类结构关系:

alt text

线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。

ThreadPoolExecutor 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。

(1)线程池的7个参数

线程池 ThreadPoolExecutor7个参数

  1. 线程池的核心线程数量int corePoolSize
  2. 线程池的最大线程数(核心线程+临时线程数):int maximumPoolSize
  3. 临时线程的最大空闲时间(超过这个时间,临时线程就释放掉):long keepAliveTime
  4. 参数3的时间单位(秒/天/...):TimeUnit unit
  5. 线程池任务队列(是一个阻塞队列,当线程数达到核心线程数后,会将任务存储在阻塞队列中):BlockingQueue<Runnable> workQueue
  6. 线程工厂(创建线程所用的工厂):ThreadFactory threadFactory
  7. 拒绝策略(当队列已满并且线程数量达到最大线程数量时,会调用该方法处理任务):RejectedExecutionHandler handler

源码如下(忽略细节):

    public ThreadPoolExecutor(
        int corePoolSize,       // 核心线程数量
        int maximumPoolSize,    // 最大线程数
        long keepAliveTime,     // 最大空闲时间
        TimeUnit unit,          // 时间单位
        BlockingQueue<Runnable> workQueue,  // 阻塞/任务队列
        ThreadFactory threadFactory,        // 线程工厂
        RejectedExecutionHandler handler    // 拒绝策略
        ) {
      ...
    }
1
2
3
4
5
6
7
8
9
10
11
1
2
3
4
5
6
7
8
9
10
11

(2)线程池的执行原理/流程

为了搞懂线程池的原理,我们需要首先分析一下 execute 方法,该方法的实现在 ThreadPoolExecutor 类中。


















 






 






 
 
 

 



 


   // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
   private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

    private static int workerCountOf(int c) {
        return c & CAPACITY;
    }
    //任务队列
    private final BlockingQueue<Runnable> workQueue;

    public void execute(Runnable command) {
        // 如果任务为null,则抛出异常。
        if (command == null)
            throw new NullPointerException();
        // ctl 中保存的线程池当前的一些状态信息
        int c = ctl.get();

        //  下面会涉及到 3 步 操作
        // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize
        // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
        // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
            if (!isRunning(recheck) && remove(command))
                reject(command);
            // 如果当前工作线程数量为0,新创建一个线程并执行。
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
        // 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
        //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
        else if (!addWorker(command, false))
            reject(command);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

线程池处理任务的流程:

图解线程池实现原理

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个核心线程 addWorker(command, true) 来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数:
    • 那么就把该任务放入到任务队列里等待执行
    • 如果当前运行的线程数为0,则新建一个临时线程 addWorker(null, false);
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个临时线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用 RejectedExecutionHandler.rejectedExecution() 方法。

(3)ThreadPoolExecutor 拒绝策略

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

扩展问题:

🧛🏻‍♂️ 问:线程池的核心线程设置为0,任务来了之后怎么做?

🙋🏻‍♂️ 答:核心线程数设置为0,当任务来了之后,也会创建一个线程来执行任务,

# ⚪ 线程池中有哪些常见的阻塞队列❓

🙋🏻‍♂️ 答:

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在此(阻塞)任务队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

阻塞队列 内置线程池 说明
LinkedBlockingQueue(无界队列) FixedThreadPoolSingleThreadExector 容量为 Integer.MAX_VALUEFixedThreadPool 最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector 只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
SynchronousQueue(同步队列) CachedThreadPool SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
DelayedWorkQueue(延迟阻塞队列) ScheduledThreadPoolSingleThreadScheduledExecutor DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

# ⚪ 如何确定核心线程数❓

🙋🏻‍♂️ 答:

  1. 高并发、任务执行时间短 →( CPU核数+1 ),减少线程上下文的切换
  2. 并发不高、任务执行时间长
    • IO密集型的任务 → (CPU核数 * 2 + 1),举例:文件读写、DB读写、网络请求等
    • 计算密集型任务 →( CPU核数+1 ),举例:计算型代码、Bitmap转换、Gson转换
  3. 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考第2条

# ⚪ 线程池的种类有哪些❓

🙋🏻‍♂️ 答:

  • newFixedThreadPool:创建一个 定长线程池,可控制线程最大并发数,超出的线程会在队列中等待
  • newSingleThreadExecutor:创建一个 单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
  • newCachedThreadPool:创建一个 可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
  • newScheduledThreadPool:可以 执行延迟任务的线程池,支持定时及周期性任务执行

# ⚪ 为什么不建议用Executors创建线程池❓

🙋🏻‍♂️ 答:

在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

另外,《阿里巴巴 Java 开发手册》中强制线程池 不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

线程池对象的弊端

# 使用场景

# ⚪ 线程池使用场景(你们项目中哪里用到了线程池)❓

🙋🏻‍♂️ 答:

  • 批量导入:使用了“线程池+CountDownLatch”批量把数据库中的数据导入到ES(任意)中,避免OOM
  • 数据汇总:调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用“线程池+Future”来提升性能
  • 异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间

# ⚪ 如何控制某个方法允许并发访问线程的数量❓

🙋🏻‍♂️ 答:

Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,底层是 AQS,我们可以通过其限制执行的线程数量。

使用场景:通常用于那些资源有明确访问数量限制的场景,常用于限流。

使用步骤:

  1. 创建 Semaphore 对象,可以给一个容量
  2. semaphore.acquire():请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
  3. semaphore.release():释放一个信号量,此时信号量个数+1
 
 




 
 













 
 




// 1. 创建 Semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 10个线程同时运行
for (int i = 0; i < 10; ++ i) {
  new Thread(() -> {
    try {
      // 2. 请求一个信号量,获取许可
      semaphore.acquire();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }

    try {
      System.out.println("running...");
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println("end...");
    } finally {
      // 3. 释放一个信号量,释放许可
      semaphore.release();
    }
  }).start();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# ⚪ 谈谈你对ThreadLocal的理解❓

🙋🏻‍♂️ 答:

  1. ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
  2. ThreadLocal 同时实现了线程内的资源共享
  3. 每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
    • 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
    • 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
    • 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
  4. ThreadLocal 内存泄漏问题 ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被 GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value

# JVM虚拟机篇

alt text

20240527153720

# JVM组成

# ⚪什么是程序计数器❓

🙋🏻‍♂️ 答:

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道了程序计数器主要有两个作用

  1. 字节码解释器 通过改变程序计数器 来依次读取指令,从而 实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于 记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
20240527153909

⚠️ 注意:程序计数器是 唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

# ⚪你能给我详细的介绍下堆吗❓

🙋🏻‍♂️ 答:

(1)Java堆

Java 堆是 JVM 所管理的 内存中最大的一块,是所有 线程共享 的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。如图所示:

Java堆分代

JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存

(2)可能的异常

堆这里最容易出现的就是 OutOfMemoryError 错误。当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出 OutOfMemoryError 异常。

(3)字符串常量池

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。

字符串常量池 是 JVM 为了提升性能和减少内存消耗 针对字符串(String 类)专门开辟的一块区域,主要目的是为了 避免字符串的重复创建

HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的 HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。


扩展问题

🧛🏻‍♂️ 问:为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

🙋🏻‍♂️ 答:

  1. 避免空间 OOM:整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  2. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  3. 元空间里面存放的是类的元数据,放在本地内存时,由系统的实际可用空间来控制,这样能加载的类就更多。

# ⚪能不能介绍一下方法区❓

🙋🏻‍♂️ 答:

关键字:类信息、运行时常量池、常量池表

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个 线程共享 的内存区域。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

(2)运行时常量池

运行时常量池是方法区的一部分

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table)常量池表会在类加载后存放到方法区的运行时常量池中

  • 字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。
  • 常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。

# ⚪你听过直接内存吗❓

🙋🏻‍♂️ 答:

直接内存:并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存,常见于 NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高

# ⚪什么是虚拟机栈❓

🙋🏻‍♂️ 答:

关键字:方法调用、栈帧

(1)概述

虚拟机栈(JVM Stack)是 Java 运行时数据区的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现 的。

栈由一个个栈帧组成,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

(2)栈帧

每一次方法调用都生成一个栈帧,栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。

  • 局部变量表:存放了编译期可知的各种数据类型、对象引用
  • 操作数栈:用于存放该方法执行过程中产生的中间计算结果、临时变量
  • 动态链接:将符号引用转换为调用方法的直接引用,便于服务一个方法需要调用其他方法的场景
  • 方法返回地址:用来存放调用该方法的 PC 寄存器的值

(3)可能的异常

  • 栈固定大小时可能 StackOverflowError
  • 栈动态扩展时可能 OutOfMemoryError

扩展问题

🧛🏻‍♂️ 问:垃圾回收是否涉及栈内存?

🙋🏻‍♂️ 答:不涉及。垃圾回收主要在堆内存区域中;对于虚拟机栈,当栈帧弹出栈以后,内存就会释放。

🧛🏻‍♂️ 问:方法内的局部变量是否线程安全?

🙋🏻‍♂️ 答:

  • 如果方法内的局部变量没有逃离方法的作用范围,那么是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

# ⚪什么情况下会导致栈内存溢出❓

🙋🏻‍♂️ 答:

如果 函数调用陷入无限循环 的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度 超过当前 Java 虚拟机栈的最大深度 的时候,就抛出 StackOverFlowError 错误。

# ⚪堆栈的区别是什么❓

🙋🏻‍♂️ 答:

栈是运行时的单位,而堆是存储的单位。

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

# 类加载器

# ⚪ 什么是类加载器,类加载器有哪些❓

🙋🏻‍♂️ 答:

类加载器:用于将字节码文件(.class 文件)加载到JVM中,实现类加载过程中的 加载/Loading 这一步,从而让Java程序能够启动起来。

站在Java开发人员的角度来看,类加载器可以大致划分为三类,这也是 JVM 中内置的三个重要 ClassLoader

  1. BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++ 实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/jre/lib 目录下的 rt.jarresources.jarcharsets.jar 等 jar 包和类)以及被 -Xbootclasspath 参数指定的路径下的所有类。启动类加载器是无法被Java程序直接引用的。
  2. ExtensionClassLoader(扩展类加载器):主要负责加载 %JAVA_HOME%/jre/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。开发者可以直接使用扩展类加载器。
  3. AppClassLoader(应用程序类加载器):面向我们用户的加载器,该类加载器由sun.misc.Launcher$AppClassLoader 来实现,负责加载当前应用 classpath 下的所有 jar 包和类。开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  4. (自定义)用户还可以 自定义类加载器,自定义类加载器需要继承 ClassLoader 抽象类,重写 findClass()loadClass() 方法

🌈 拓展一下:

  • rt.jar:rt 代表“RunTime”,rt.jar 是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.* 都在里面,比如 java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*。 Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

# ⚪ 什么是双亲委派模型❓

🙋🏻‍♂️ 答:

当某个类加载器打算加载某一个类时,不会自己去执行,而是先委托上一级的加载器进行加载。如果上级加载器也有上级,则会继续向上委托,如果该类委托上级加载失败,子加载器再尝试加载该类。

简单总结一下双亲委派机制的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过 findLoadedClass(name)。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 parent.loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

# ⚪ JVM为什么采用双亲委派机制❓

🙋🏻‍♂️ 答:

  • 避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  • 保证了 Java 的核心 API 不被篡改。

# ⚪ 说一下类装载的执行过程❓

🙋🏻‍♂️ 答:

类装载的整个生命周期:

20240527194710

类加载过程

  • Step1 加载/Loading
    • 查找并加载类的二进制字节流
    • 将字节流所代表的 静态存储结构 转换为 方法区的运行时数据结构
    • 在内存(Java堆)中生成一个代表该类的「Class对象」,作为方法区中这些数据的访问入口
  • Step2 验证/Verifcation
    • 确保被加载的类的正确性,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
  • Step3 准备/Preparation
    • 为类的静态变量分配内存(方法区中分配),并将其初始化为默认值;
  • Step4 解析/Resolution
    • 把类中常量池内的「符号引用」替换为「直接引用」;
    • alt text
  • Step5 初始化/Initialization
    • 执行初始化方法 <clinit>(),为类的静态变量赋予正确的初始值
    • 只有当对类的主动使用的时候才会导致类的初始化,有以下6种情况:
      • 创建类的实例,也就是 new 的方式
      • 访问某个类或接口的静态变量,或者对该静态变量赋值
      • 调用类的静态方法
      • 反射(如Class.forName("com.pdai.jvm.Test"))
      • 初始化某个类的子类,则其父类也会被初始化
      • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用 java.exe 命令来运行某个主类

# 垃圾回收

# ⚪ 对象什么时候可以被垃圾器回收❓

🙋🏻‍♂️ 答:

对堆进行垃圾回收(GC)前的第一步就是要判断哪些对象已经死亡。

简单来说,如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

要定位哪些对象是垃圾,有 两种方式 来确定,第一个是引用计数法,第二个是可达性分析算法(JVM采用这种)。

(1)引用计数法

给对象添加一个引用计数器。1)每当有一个地方引用它,计数器+1;2)当引用失效,计数器-1;3)计数器为 0的对象就是垃圾。

优点:实现简单,效率高
缺点:很难解决对象之间循环引用的问题

(2)可达性分析算法(JVM采用这种)

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

哪些对象可以作为 GC Roots

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

# ⚪ 强引用、软引用、弱引用、虚引用❓

🙋🏻‍♂️ 答:

JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

Java 具有四种强度不同的引用类型:

  1. 强引用(StrongReference)
    • 被强引用关联的对象 不会被回收
    • 创建方式:
      Object obj = new Object();
      
      1
      1
  2. 软引用(SoftReference)
    • 被软引用关联的对象 只有在内存不够的情况下才会被回收
    • 创建方式:
      Object obj = new Object();
      SoftReference<Object> sf = new SoftReference<Object>(obj);
      obj = null;  // 把强引用 obj 释放掉,使对象只被软引用 sf 关联
      
      1
      2
      3
      1
      2
      3
  3. 弱引用(WeakReference)
    • 被弱引用关联的对象 一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
    • 创建方式:
      Object obj = new Object();
      WeakReference<Object> wf = new WeakReference<Object>(obj);
      obj = null;
      
      1
      2
      3
      1
      2
      3
  4. 虚引用(PhantomReference)
    • 形同虚设,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在 任何时候都可能被垃圾回收
    • 虚引用主要用来跟踪对象被垃圾回收的活动。
    • 创建方式:
      Object obj = new Object();
      PhantomReference<Object> pf = new PhantomReference<Object>(obj);
      obj = null;
      
      1
      2
      3
      1
      2
      3

在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

# ⚪ JVM 垃圾回收算法有哪些❓

🙋🏻‍♂️ 答:

  1. 标记-清除算法

「标记-清除」(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  • 效率问题:标记和清除两个过程效率都不高。
  • 空间问题:标记清除后会产生大量不连续的内存碎片。
标记-清除算法
  1. 复制算法

为了解决「标记-清除」算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。
复制算法

现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

  1. 标记-整理算法

「标记-整理」(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与「标记-清除」算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景

标记-整理算法
  1. 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

比如:

  • 新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择「标记-清除」或「标记-整理」算法进行垃圾收集。

# ⚪ 说一下JVM中的分代回收❓

🙋🏻‍♂️ 答:

(1)分代

为了有助于优化对堆内存垃圾回收的性能,将内存划分为 新生代(Young Generation)和老年代(Old Generation)。

新生代

  • 主要用于存放新创建的对象。新生代会分为三个区域:
    • Eden(伊甸园,80%)
    • S0(Survivor 0 区,10%)
    • S1(Survivor 1 区,10%)

老年代

  • 一方面,用于存放那些在新生代中经历了多次垃圾回收仍然存活的对象。这些对象通常生命周期较长。
  • 另一方面,大对象直接进入老年代,这是内存分配策略之一。
  • (延伸)当老年代空间不足以容纳新存活的对象时,会触发 Full GC,这种GC通常比Minor GC耗时更长,因为它涉及的对象更多,且可能需要暂停所有应用线程。

(2)GC 种类

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

  • 部分收集 (Partial GC)
    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
    • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

(3)分代垃圾回收算法执行过程

  • 初始态:对象分配在Eden区,S0、S1区几乎为空。
  • 随着程序的运行,越来越多的对象被分配在Eden区。
  • 当Eden放不下时,就会发生MinorGC(即YoungGC),此时,会先标识出不可达的垃圾对象,然后将可达的对象移动到S0区,并将不可达的对象清理掉。这时候,Eden区就是空的了。在这个过程中,使用了标记清理算法及标记复制算法。
  • 随着Eden放不下时,会再次触发minorGC,和上一步一样,先标记。这个时候,Eden和S0区可能都有垃圾对象了,而S1区是空的。这个时候,会直接将Eden和S0区的对象直接搬到S1区,然后将Eden与S0区的垃圾对象清理掉。经历这一轮的MinorGC后,Eden与S0区为空。
  • 随着程序的运行,Eden空间会被分配殆尽,这时会重复刚才MinorGC的过程,不过此时,S0区是空的,S0和S1区域会互换,此时存活的对象会从Eden和S1区,向S0区移动。然后Eden和S1区中的垃圾会被清除,这一轮完成之后,这两个区域为空。
  • 在程序运行过程中,虽然大多数对象都会很快消亡,但仍然存在一些存活时间较长的对象,对于这些对象,在S0和S1区中反复移动,会造成一定的性能开销,降低GC的效率。因此引入了对象晋升的行为。
  • 当对象在新生代的Eden、S0、S1区域之间,每次从一个区域移动到另一个区域时,年龄都会加一,在达到一定的阈值后,如果该对象仍然存活,该对象将会晋升到老年代。
  • 如果老年代也被分配完毕后,就会出现MajorGC(即Full GC),由于老年代通常对象比较多,因此标记-整理算法的耗时较长,因此会出现STW现象,因此大多数应用都会尽量减少或着避免出现Full GC的原因。

# ⚪ 说一下JVM有哪些垃圾回收器❓

🙋🏻‍♂️ 答:

以下是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

7 个垃圾收集器

前置知识:

  • 单线程&多线程: 「单线程」指的是垃圾收集器只使用一个线程进行收集,而「多线程」使用多个线程;
  • 串行&并行: 「串行」指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序(必须暂停其他所有的工作线程("Stop The World"));「并行」指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行

简短总结 7 个垃圾收集器:

  • SerialSerial Old:单线程收集器,适用于客户端
  • Parallel ScavengeParallel Old:多线程收集器,适用于注重吞吐量以及 CPU 资源敏感的场合
  • ParNew:Serial 的多线程版本,适用于服务端,只有它能与 CMS 收集器配合工作
  • CMS
    • 全称 Concurrent Mark Sweep,标记-清除算法,用于老年代GC
    • 第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
    • 它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
      • Step1 初始标记暂停所有的其他线程,并记录下 GC Roots 能直接关联到的对象,速度很快;(需要停顿)
      • Step2 并发标记同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方;(不需要停顿)
      • Step3 重新标记: 为了修正 Step2并发标记期间 因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;(需要停顿)
      • Step4 并发清除开启用户线程,同时 GC 线程开始对未标记的区域做清扫。(不需要进行停顿)
    • 从 JDK9 开始,CMS 收集器已被弃用(G1 垃圾收集器成为了默认的垃圾收集器)
  • G1(下一个问题详细阐述):JDK9之后默认使用G1,可以直接对新生代和老年代一起回收,复制算法

# ⚪ 详细聊一下G1垃圾回收器❓

🙋🏻‍♂️ 答:

JDK9之后默认使用G1,G1可以直接对新生代和老年代一起回收,采用复制算法。

G1 把堆划分成多个大小相等的 独立区域(Region) ,新生代和老年代不再物理隔离,如图所示:

alt text

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。同时,每个小空间都可以充当 Eden,Survivor(s0,s1),Old, Humongous,其中 Humongous 专为大对象准备。

G1 收集器通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

# JVM 实践

# ⚪ JVM 调优的参数可以在哪里设置❓

🙋🏻‍♂️ 答:

  1. 方式1:Tomcat:war 包部署在 Tomcat 中设置

修改 TOMCAT_HOME/bin/catalina.sh 文件(针对Linux,Win下则是 .bat 后缀)

Linux下catalina.sh
  1. 方式2:Springboot:jar 包部署在启动参数设置

通常在 Linux 系统下直接加参数启动 Springboot 项目,命令:

nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &

# ⚪ 用的 JVM 调优的参数都有哪些❓

🙋🏻‍♂️ 答:

官网所有的 JVM 调优参数:https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html (opens new window)

列举一些比较常见的:

  • 设置堆空间大小
    • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
    • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize
    // 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64
    -Xms1024   // 不指定单位默认为字节
    -Xms1024k
    -Xms1024m
    -Xms1g
    
    1
    2
    3
    4
    5
    1
    2
    3
    4
    5
  • 虚拟机栈的设置
    • -Xss:每个线程的虚拟机栈的大小
    // 每个线程默认会开启1M的内存
    -Xss128k  // 对每个线程 stack 大小的调整
    
    1
    2
    1
    2
  • 年轻代中Eden区和两个Survivor区的大小比例
    • 默认比例为 Eden:s0:s1 = 8:1:1
    • -XX:SurvivorRatio:表示年轻代中的分配比例
    -XX:SurvivorRatio=8    
    
    1
    1
  • 年轻代晋升老年代阈值 -XX:MaxTenuringThreshold:年轻代晋升老年代阈值
  • 设置垃圾回收收集器
    • -XX:+UseParallelGC
    • -XX:+UseParallelOldGC
    • -XX:+UseG1GC

# ⚪ 说一下 JVM 调优的工具❓

🙋🏻‍♂️ 答:

  • 命令工具
    • jps 进程状态信息
    • jstack 查看java进程内线程的堆栈信息
    • jmap 查看堆转信息
    • jhat 堆转储快照分析工具
    • jstat JVM统计监测工具
  • 可视化工具
    • jconsole 用于对jvm的内存,线程,类 的监控(可以检测死锁)
    • VisualVM 能够监控线程,内存情况

# ⚪ Java内存泄露的排查思路❓

🙋🏻‍♂️ 答:

内存泄漏,指丢失了数据的地址,没法引用也没法删除。

以下内容可能是内存溢出,而不是内存泄漏的排查思路

20240528011040

如图所示,JVM Stack 一般是递归循环造成的 StackOverFlowError 内存溢出;方法区一般是加载的类信息太多导致 OutOfMemoryError:Metaspace 内存不足;Heap 比较常见 OutOfMemoryError:java heap space 报错,面试官也比较关系这部分。

内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况。

排查思路:

1、通过jmap或设置jvm参数获取堆内存快照dump 2、通过工具, VisualVM去分析dump文件,VisualVM可以加载离线的dump文件 3、通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题 4、找到对应的代码,通过阅读上下文的情况,进行修复即可

# ⚪ CPU飙高排查方案与思路❓

🙋🏻‍♂️ 答:

  1. Step1:使用top命令查看占用cpu的情况
  2. Step2:通过top命令查看后,可以查看是哪一个进程占用cpu较高(确定Java程序的进程PID)
  3. Step3:使用ps命令查看进程中的线程信息(根据进程PID找到其中的每个线程PID,由哪个线程PID导致的CPU高占有率)
  4. Step4:使用jstack命令查看进程中哪些线程出现了问题,最终定位问题(根据线程PID,找到对应栈顶栈帧,定位代码行号)

总结topps H -eo pid,tid,%cpu | grep <pid>jstack <pid> → 找到对应nid → 栈顶栈帧

# 框架篇