0%

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;

  • jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;gc情况、垃圾回收统计

  • jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;

  • jmap (Memory Map for Java) : 生成堆转储快照;内存布局、堆信息

  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;

  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

1 JVM参数

image.png
image.png

1.1 堆参数

初始堆(memory start)大小、最大堆大小
-Xms<heap size>[unit]
-Xmx<heap size>[unit]


年轻代大小
-XX:NewSize=n
-XX:NewRatio=n 年轻代和年老代比值为1:n
-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。



格式:-XX:[+-]<name> 表示启用或者禁用name属性。
例子:-XX:+UseG1GC(表示启用G1垃圾收集器)

2 jstack :生成虚拟机当前时刻的线程快照

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合.

生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。

3 OOM类型

  • 虚拟机栈:

  • 固定大小:申请不到栈帧报 StackOverflow

  • 无限大小:申请不到栈帧报 OutOfMemory

  • 堆:忘记是不是能固定大小了

  • jvm内存满了,对象分配不到堆了就会报OutOfMemory

  • 方法区:(面试官提示,感谢)

  • 方法区里面的常量池也是会不断加入数据的

  • 一直调用string.intern()方法会不断加字符串到字符串常量池导致OOM

4 OOM排查

OOM 全称 “Out Of Memory”,表示内存耗尽。当 JVM 因为没有足够的内存来为对象分配空间,并且垃圾回收器也已经没有空间可回收时,就会抛出这个错误。产生原因:

  1. 分配过少:JVM 初始化内存小,业务使用了大量内存;或者不同 JVM 区域分配内存不合理
  2. 代码漏洞:某一个对象被频繁申请,不用了之后却没有被释放,导致内存耗尽

内存泄漏申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了。因为申请者不用了,而又不能被虚拟机分配给别人用

内存溢出:申请的内存超出了 JVM 能提供的内存大小,此时称之为溢出

4.1 OOM类型

java.lang.OutOfMemoryError: PermGen space

Java7 永久代(方法区)溢出,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。每当一个类初次加载的时候,元数据都会存放到永久代

一般出现于大量 Class 对象或者 JSP 页面,或者采用 CgLib 动态代理技术导致

我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 修改方法区大小

Java8 将永久代变更为元空间,报错:java.lang.OutOfMemoryError: Metadata space,元空间内存不足默认进行动态扩展

java.lang.StackOverflowError

虚拟机栈溢出,一般是由于程序中存在 死循环或者深度递归调用 造成的。如果栈大小设置过小也会出现溢出,可以通过 -Xss 设置栈的大小

虚拟机抛出栈溢出错误,可以在日志中定位到错误的类、方法

java.lang.OutOfMemoryError: Java heap space

Java 堆内存溢出,溢出的原因一般由于 JVM 堆内存设置不合理或者内存泄漏导致

如果是内存泄漏,可以通过工具查看泄漏对象到 GC Roots 的引用链。掌握了泄漏对象的类型信息以及 GC Roots 引用链信息,就可以精准地定位出泄漏代码的位置

如果不存在内存泄漏,就是内存中的对象确实都还必须存活着,那就应该检查虚拟机的堆参数(-Xmx 与 -Xms),查看是否可以将虚拟机的内存调大些

tasklist | findstr "java"

查看内存堆栈信息
//查看jvm内存分布
jmap -heap pid
jhsdb jmap --heap --pid 16279
(对于jdk8之后的版本,不能再使用jmap -heap pid的命令了,需要使用上面的命令)。

- 查看gc情况
jstat -gc pid 刷新时间
例如:jstat -gc 1289 5000 表示每5秒查看一次pid为1289的gc时间。
- 查看内存对象实例数量
jmap -histo:live 16279 > c.jmap
将结果导入到c.jmap的文件中,这个是自定义的文件。

4.2 案例1(Xmn=Xmx,老年代内存为0)

Heap Space Size = Young Space Size + Old Space Size,而-Xmn参数控制的正是 Young 区的大小,当堆区被 Young Gen 完全挤占,又有对象想要升代到 Old Gen 时,发现 Old 区空间不足,于是触发 Full GC,触发 Full GC 以后呢,通常又会面临两种情况:

  • Young 区又刚好腾出来一点空间,对象又不用放到 Old 区里面了,皆大欢喜

  • Young 区空间还是不够,对象还是得放到 Old 区,Old 区空间不够,卒,喜提OOM

  • 诶,就是奔着 Old 区去的,管你 Young 不 Young,Old 区空间不够,卒,喜提OOM

这个就解释了为什么系统刚刚启动时,会有一个短时间正常工作的现象,随后,当某段程序触发 Old Gen 升代时,就会发生随机的OOM错误。那么什么时候对象会进入老年代呢?这里也很有意思,不妨结合日志里面出现OOM的地方,对号入座:

  • 经历足够多次数 GC 依然存活的对象

  • 申请一个大对象(比如超过 Eden 区一半大小)

  • GC 后 Eden 区对象大小超过 S 区之和

  • Eden 区 + S0 区 GC 后,S1 区放不下

换言之,正常情况下,-Xmn参数总是应当小于-Xmx参数,否则就会触发OOM错误。

5 CPU占用过高,排查和处理

在Linux环境下,项目出现CPU占用过高的情况时,可以按照以下步骤进行排查和处理:

  1. 定位高CPU占用的进程

    • 使用top命令查看系统中CPU占用率最高的进程。
  2. 分析进程中的线程

    • 如果发现某个进程的CPU占用率特别高,可以使用top -H -p [PID]来查看该进程中各个线程的CPU占用情况。
    • 找出占用CPU最高的线程ID。
  3. 转换线程ID为16进制

    • 使用printf "%x\n" [线程ID]命令将线程ID转换为16进制格式。
  4. 获取线程堆栈信息

    • 使用jstack [进程PID] | grep [线程ID的16进制] -A 30命令获取该线程的Java堆栈信息(如果是Java进程)。这可以帮助定位到具体的代码行或方法调用。
    • 如果不是Java进程,可以使用gdb或其他相应的调试工具来获取线程的堆栈信息。
  5. 分析代码和日志

    • 根据堆栈信息,检查相关的代码逻辑,看是否有死循环、资源泄露、复杂计算等导致CPU占用过高的问题。
    • 同步问题导致的死锁、过度的上下文切换,或者资源竞争等问题。这可能会涉及到分析操作系统级别的线程调度,JVM内部锁的状态,以及可能的I/O等待、网络延迟等问题。
    • 同时检查应用程序的日志,看是否有异常或错误信息与高CPU占用相关。
  6. 性能剖析

    • 使用性能剖析工具(如VisualVM, YourKit, JProfiler等)进行实时监控,找出CPU占用率高的方法。
- 这些工具可以提供热点(hot spots)功能,显示哪些方法占用最多的CPU时间。
  1. 处理措施

CPU飙高的排查方案及思路_cpu冲高排查思路-CSDN博客

6 参考

JVM参数最重要的JVM参数总结 | JavaGuide
美团面试:熟悉哪些JVM调优参数,幸好我准备过!-CSDN博客

JDK监控和故障处理工具总结 | JavaGuide
生产事故-记一次特殊的OOM排查 - 程语有云 - 博客园 (cnblogs.com)
YGC问题排查,又让我涨姿势了! | HeapDump性能社区

1 序列化、反序列化

序列化是将数据结构或对象转换成一种可存储或可传输格式的过程,通常涉及将数据转换成字节流或类似的格式,以便在不同平台和编程语言之间进行传输和交换。反序列化是将序列化后的数据重新还原成原始的数据结构或对象,从文件、网络数据或数据库中读取序列化的数据,并将其转换回原始形式,以便在程序中进行使用和操作。这两个概念在编程中与数据存储、传输和处理密切相关,为开发工作带来便利1

2 异常

image.png|500

2.1 Exception和Error

  • Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。

  • ErrorError 属于程序无法处理的错误

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException(空指针错误)

  • IllegalArgumentException(参数错误比如方法入参类型错误)

  • NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类,s包含非数字字符,或者为null)

  • ArrayIndexOutOfBoundsException(数组越界错误)

  • ClassCastException(类型转换错误)

  • ArithmeticException(算术错误)

  • SecurityException (安全错误比如权限不够)

  • UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)

2.1.1 如何使用 try-with-resources 代替try-catch-finally

//读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}

使用 Java 7 之后的 try-with-resources 语句改造上面的代码:

try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}

3 泛型

在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的 <E>

泛型一般有三种使用方式:泛型类泛型接口泛型方法

1 Linux运行级别

Linux系统有七个运行级别,分别用数字0-6表示。每个运行级别都会启动或停止不同的系统服务和进程,以满足不同的系统需求。

​1.我们可以使用 runlevel 命令,进行查看

2.我们可以使用 who -r 命令进行查看

运行等级 描述 命令参数
0 关机模式,系统默认运行级别不能设置为0,否则不能正常启动,一开机就自动关机 shutdown.target
1 单用户模式,root权限,用于系统维护,禁止远程登录,就像Windows下的安全模式 emergency.target
2 多用户模式,没有 NFS 网络支持 rescure.target
3 完整的多用户文本模式,有 NFS,登录后进入控制台命令模式 multi-user.target
4 系统未使用,保留一般不用
5 图形化模式,登陆后进入图形 GUI 模式 graphical.target
6 重启模式,默认运行级别不能设为6,否则不能设为6,否则不能正常启动,就会一直开机重启

我们怎么查看系统在启动时默认的运行等级呢?

可以使用 systemctl get-default 命令进行查看
 systemctl set-default multi-user.target

要改变Linux的运行级别,可以使用以下方法:使用runlevel命令查看当前系统的运行级别。使用init命令切换运行级别。修改/etc/inittab文件中的运行级别。使用systemctl命令设置默认运行级别。使用systemctl命令立即切换到指定的运行级别。

2 Linux常用命令

  • chmod:更改文件或目录的权限。

  • cat:查看文件内容

  • top:inux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况.该命令可以提供实时的对系统处理器的状态监视,它会显示系统中CPU最“敏感”的任务列表,并且可以按CPU使用、内存使用和执行时间对任务进行排序

2.1 grep

分析一行信息

命令 说明
-v 排除选项
grep -v ‘^$’ filename ^:行首,$:行尾,去掉空行
grep -v ‘#|$’ filename ^#:以#开头,去掉空行和以#开头的行

2.2 tar

压缩命令
tar:该命令用于将多个文件或目录打包成一个文件,也可以同时对其进行压缩。常用的参数有:
-c:创建新的压缩文件。
-v:显示详细的压缩过程。
-f:指定压缩文件的名称。
-z:使用gzip压缩算法进行压缩。
-j:使用bzip2压缩算法进行压缩。
-J:使用xz压缩算法进行压缩。

解压命令:
tar:该命令不仅可以用于压缩,也可以用于解压。解压时常用的参数有:
-x:从压缩文件中提取文件或目录。
-v:显示详细的解压过程。
-f:指定要解压的压缩文件名称。
-z:解压使用gzip压缩算法的文件。
-j:解压使用bzip2压缩算法的文件。
-J:解压使用xz压缩算法的文件。
例如,要解压doc.tar.gz到当前目录,可以使用命令tar -xzvf doc.tar.gz。

2.3 top

top命令是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况

2.4 ps

ps命令用于报告当前系统的进程状态。ps命令是最基本同时也是非常强大的进程查看命令,使用该命令可以确定有哪些进程正在运行和运行的状态、进程是否结束、进程有没有僵死、哪些进程占用了过多的资源等等,总之大部分信息都是可以通过执行该命令得到的。

2.5 free

free命令可以显示当前系统未使用的和已使用的内存数目,还可以显示被内核使用的内存缓冲区。

面试官常考的 21 条 Linux 命令 - 简书 (jianshu.com)

3 文件系统

3.1 Linux相关:文件的概念?文件有哪些类型?各自的作用是什么?

解析:: 文件类型很多,能回答几种常见的就行,例如普通文件,目录文件,块设备文件,套接字文件。 参考回答:

1.普通文件(-):这是最常见的文件类型,包括纯文本文件、二进制文件、数据文件等。它们不包含文件系统的结构信息,只是用户所接触到的文件。例如,.c文件、可执行的二进制文件等都是普通文件。

2.目录文件(d):目录文件是用于存放文件名及其相关信息的文件。它们可以包含下一级文件目录或普通文件,是内核组织文件系统的基本节点。通过目录文件,用户可以轻松地浏览和管理文件系统。

3.字符设备文件(c):这类文件提供了对设备不带缓冲区的访问,每次访问长度可变。它们通常用于表示系统中的字符设备,如控制台、串口等。

4.块设备文件(b):块设备文件提供对设备(如磁盘)带缓冲的访问,每次访问以固定的长度单位进行。它们用于表示系统中的块设备,如硬盘、U盘等。

5.FIFO(p):FIFO文件也称为命名管道,用于进程间的通信。它们允许一个进程向另一个进程发送数据,而不需要通过中间的文件或网络连接。

6.套接字(s):套接字文件用于进程间的网络通信。它们提供了一种在不同进程之间传输数据的方式,通常用于实现网络服务和客户端之间的通信。

7.链接文件(l):链接文件是指向另一个文件的指针。它们可以分为硬链接和符号链接两种。硬链接指向文件的inode节点,而符号链接则指向另一个文件的路径名。通过链接文件,用户可以方便地访问其他文件或目录。

除了以上七种常见的文件类型外,Linux系统中还有其他一些特殊的文件类型,如特殊文件、门文件等,但这些类型在日常使用中相对较少见。

学习指引: 推荐学习:小林 coding|图解系统|文件系统

3.2 Linux相关:inode的作用?inode包含哪些内容?给出一个文件名,Linux是如何根据该文件名打开文件的?(文件名->inode->block)文件的访问时间是如何记录的?

解析:: 考察linux文件系统相关问题,比较细,推荐大家系统学习后理解掌握 参考回答:

1.inode的作用?: inode,即索引节点,在Linux文件系统中用于存储文件或目录的元数据信息。它是文件系统的一个基本组成部分,允许系统通过inode号而非完整的文件路径快速访问到文件数据。

2.inode包含哪些内容?: inode包含文件的元数据信息,如文件大小、文件所有者、文件权限、文件类型、文件的创建/访问/修改时间等。此外,inode还包含指向文件数据块的指针,这些指针指示了文件内容在磁盘上的实际存储位置。

3.给出一个文件名,Linux是如何根据该文件名打开文件的? (文件名->inode->block): 当给出一个文件名时,Linux首先会根据文件路径在目录结构中查找该文件对应的目录项。目录项中包含了文件的inode号。然后,系统会使用这个inode号在文件系统中找到对应的inode结构。一旦找到inode,系统就可以通过inode中的指针找到文件数据所在的磁盘块(block)。最后,系统将这些磁盘块加载到内存中,从而打开并访问文件。

4.文件的访问时间是如何记录的?: 文件的访问时间是通过inode中的访问时间戳(atime)来记录的。每当文件被读取时,其inode中的atime就会被更新为当前时间。这个机制允许系统跟踪文件的访问历史,以便进行各种管理和维护操作。需要注意的是,为了优化性能,某些文件系统可能会延迟更新atime或仅在文件内容实际被读取时更新它。

阿里云 实习面经(已OC) 一面面经|讲解_牛客网 (nowcoder.com)

3.3 零拷贝是什么?用来解决什么问题?有哪些应用场景?实现方式有哪些?

解析:

参考回答:

1.是什么?:零拷贝是一种IO操作优化技术,旨在减少数据在内核空间和用户空间之间的冗余拷贝,从而解放CPU、减少上下文切换并降低系统资源消耗。它主要用来解决传统IO操作中不必要的数据拷贝问题,提高数据传输效率。

2.应用场景:零拷贝技术广泛应用于需要高性能数据传输的场景,如网络传输、文件传输、数据库操作等。在这些场景中,大量的数据需要在内核空间和用户空间之间传输,传统的IO操作会导致不必要的数据拷贝和性能损失。

3.实现方式有哪些?:实现零拷贝的方式主要有mmap、sendfile、splice和tee等。其中,mmap通过内存映射将内核缓冲区与用户空间共享,避免了数据拷贝;sendfile直接将数据从内核缓冲区发送到网络缓冲区,减少了CPU拷贝;splice和tee则在内核空间内实现数据的传输和复制,避免了用户空间的参与。这些技术根据具体的应用场景和需求选择使用,可以有效地提高数据传输效率和系统性能。

学习指引: 图解系统:什么是零拷贝?如何实现零拷贝?

/**  
* Executes the given task sometime in the future. The task * may execute in a new thread or in an existing pooled thread. * * If the task cannot be submitted for execution, either because this * executor has been shutdown or because its capacity has been reached, * the task is handled by the current {@code RejectedExecutionHandler}.
* * @param command the task to execute
* @throws RejectedExecutionException at discretion of
* {@code RejectedExecutionHandler}, if the task
* cannot be accepted for execution * @throws NullPointerException if {@code command} is null
*/public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps: * * 1. If fewer than corePoolSize threads are running, try to * start a new thread with the given command as its first * task. The call to addWorker atomically checks runState and * workerCount, and so prevents false alarms that would add * threads when it shouldn't, by returning false. * * 2. If a task can be successfully queued, then we still need * to double-check whether we should have added a thread * (because existing ones died since last checking) or that * the pool shut down since entry into this method. So we * recheck state and if necessary roll back the enqueuing if * stopped, or start a new thread if there are none. * * 3. If we cannot queue task, then we try to add a new * thread. If it fails, we know we are shut down or saturated * and so reject the task. */
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}

硬核干货:4W字从源码上分析JUC线程池ThreadPoolExecutor的实现原理 | Throwable (throwx.cn)

ArrayList: 动态数组,实现了List接口,支持动态增长。
LinkedList: 双向链表,也实现了List接口,支持快速的插入和删除操作。
HashMap: 基于哈希表的Map实现,存储键值对,通过键快速查找值。
HashSet: 基于HashMap实现的Set集合,用于存储唯一元素。
TreeMap: 基于红黑树实现的有序Map集合,可以按照键的顺序进行排序。
LinkedHashMap: 基于哈希表和双向链表实现的Map集合,保持插入顺序或访问顺序。
PriorityQueue: 优先队列,可以按照比较器或元素的自然顺序进行排序。

Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:ListSetQueue
image.png

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。

  • Set(注重独一无二的性质): 存储的元素不可重复的。

  • Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。

  • Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

集合 实现 特点
List ArrayList
vector 线程安全
LinkedList
Set HashSet
LinkedHashSet
TreeSet
Map HashMap 线程不安全,key、value都可为null
LinkedHashMap
HashTable 线程安全,数组+链表组成的,数组是 HashTable 的主体,链表则是主要为了解决哈希冲突而存在的。HashTable 的加锁方法是给每个方法加上synchronized 关键字,不支持null键和值
TreeMap 红黑树
ConcurrentHashMap Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized)

Queue

  • PriorityQueue

  • DelayQueue

  • ArrayDeque

HashSetTreeSetArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。

1 List

1.1 ArrayList和LinkedList区别

  1. 底层数据结构:

    • ArrayList使用动态数组实现。它的内部是一个数组,当数组容量不足时,会自动进行扩容。
    • LinkedList使用双向链表实现。每个元素都包含一个指向前一个元素和一个指向后一个元素的引用。
  2. 随机访问性能:

    • ArrayList支持快速的随机访问,因为它是基于数组的,可以通过索引直接访问元素。
    • LinkedList在随机访问时性能较差,因为必须从链表的头部或尾部开始遍历,直到找到目标元素。
  3. 插入和删除操作性能:

    • ArrayList在中间插入或删除元素时性能较差,因为需要移动数组中的元素。
    • LinkedList在插入和删除元素时性能较好,因为只需要改变相邻元素的引用。
  4. 空间复杂度:

    • ArrayList相对较省空间,因为它只需要存储元素值和数组容量。
    • LinkedList相对较耗费空间,因为每个元素都需要额外的两个引用字段。
  5. 迭代器性能:

    • ArrayList上的迭代器性能较好,因为它可以通过索引直接访问元素。
    • LinkedList上的迭代器性能较差,因为必须沿着链表一个一个地移动。
  6. 适用场景:

    • 如果需要频繁进行随机访问,使用ArrayList更为合适。
    • 如果需要频繁进行插入和删除操作,特别是在集合的中间位置,使用LinkedList更为合适。

2 Set

2.1 Comparable和Comparator

Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:

  • Comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序

  • Comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

2.2 Hashset、LinkedHashSet、TreeSet

  • HashSetLinkedHashSetTreeSet 的主要区别在于底层数据结构不同。HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。

  • 底层数据结构不同又导致这三者的应用场景不同。HashSet 用于不需要保证元素插入和取出顺序的场景,LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet 用于支持对元素自定义排序规则的场景。

3 Map

3.1 HashMap和TreeMap

相比于HashMap来说, TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

3.2 HashMap

[!note]
JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间

在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。

3.2.1 哈希冲突解决方式

  • 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。

  • 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。

  • 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。

  • 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。

3.2.2 hashcode和equals

equals相等的两个对象,hashCode一定相等。hashCode相等的两个对象,equals不一定相等

3.2.3 扩容机制

  1. 初始容量和负载因子:

    • HashMap 有一个初始容量和一个负载因子。初始容量是哈希表在创建时的容量,默认为 16。负载因子是一个在哈希表大小超过其容量乘以负载因子时,哈希表将进行扩容的阈值,默认为 0.75。
  2. 扩容操作:

3.2.4 HashMap多线程导致死循环

当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。

疫苗:Java HashMap的死循环 | 酷 壳 - CoolShell

3.2.5 HashMap为什么线程不安全

多线程环境下,HashMap 扩容时会造成死循环数据丢失的问题。在 HashMap 中,多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对HashMap的put操作会导致线程不安全,可能产生数据覆盖.
数据不一致

3.2.3.1 同时检测到桶位置为空,插入元素

  • 两个线程同时进行put操作,并且存在哈希冲突

  • 由于线程首先都执行完了hash碰撞的判断,桶为空

  • 每个线程再向空桶中插入元素

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 判断是否出现 hash 碰撞
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
// ...
}

3.2.3.2 多个线程同时put操作导致size不正确

  • 两个线程都先获取size,在++size

  • 添加两次元素,而size只增加1

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}

基于JDK8的HashMap实现(万字详解) - 知乎 (zhihu.com)

3.2.6 底层实现

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

3.2.7 put添加元素过程

  1. 计算哈希码(Hash Code):

    • 当你向 HashSet 中添加一个元素时,首先会调用该元素的 hashCode() 方法,得到元素的哈希码。
    • 如果元素为 null,则它的哈希码为 0。
  2. 映射到桶位置(Bucket Position):

    • 哈希码经过一系列的变换和运算,被映射到哈希表中的一个桶位置(bucket position)。
    • 桶位置是一个数组索引,表示存储元素的位置。
  3. 处理哈希冲突:

    • 哈希表可能存在冲突,即不同元素映射到相同的桶位置。为了解决冲突,HashSet 使用链表或红黑树(在JDK 8之后)来存储相同桶位置上的元素。
    • 如果桶位置上已经有一个元素,新元素会被添加到链表或红黑树的末尾。
  4. 检查元素唯一性:

    • 在添加元素的过程中,HashSet 会通过调用元素的 equals() 方法来检查元素的唯一性。
    • 如果已经存在相同的元素(根据 equals() 判断),新元素不会被加入。

3.2.8 如何做到让HashMap线程安全

  1. 使用Collections.synchronizedMap方法:
    Map<K, V> synchronizedMap = Collections.synchronizedMap(new HashMap<K, V>());

    这将返回一个线程安全的Map,它在每个方法上都使用同步机制来确保线程安全。但请注意,虽然这确保了每个方法的原子性,但在多个操作之间,仍然可能需要额外的同步。

  2. 使用ConcurrentHashMap ConcurrentHashMap是Java提供的线程安全的Map实现。它使用分段锁机制,每个段相当于一个小的HashMap,不同的段之间互不影响,这样可以提高并发性能。

    Map<K, V> concurrentMap = new ConcurrentHashMap<K, V>();

3.3 ConcurrentHashMap

[!note]
ConcurrentHashMap:Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized)

3.3.1 JDK 1.7

Segment数组,通过分段锁(Segmentation)实现线程安全。它将整个哈希表分成多个段(Segment),每个段都是一个小的哈希表,并且拥有自己的锁。这样,多个线程可以并发地访问不同的段,从而减少了锁的竞争,提高了并发性能。

image.png

3.3.2 JDK 1.8

在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。

直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。

  • 对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。

  • 如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。

  • 如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。

使用了一种更细粒度的锁策略,结合CAS(Compare-and-Swap)无锁算法来实现线程安全。在JDK 1.8中,ConcurrentHashMap将整个哈希表看作一个整体,不再进行分段。而是通过Node数组+链表+红黑树的结构来存储数据,并使用Synchronized和CAS来协调并发访问。

锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
image.png

3.3.3 JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?

  • 线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。

  • Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。

  • 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

ConcurrentHashMap 源码分析 | JavaGuide
一文彻底搞懂CAS实现原理 & 深入到CPU指令 - 知乎 (zhihu.com)
Java集合常见面试题总结(下) | JavaGuide

4 Atomic

java.util.concurrent.atomic 包提供了一组用于在多线程环境中进行原子操作的类。这些类通过使用硬件级别的原子性操作或者利用 sun.misc.Unsafe 提供的 CAS(Compare-And-Swap)操作来确保对变量的操作是原子的。这些类大多数都是基于原始数据类型的,例如 intlong,还有一些是引用类型。

以下是 java.util.concurrent.atomic 包中一些主要的类以及它们的用途:

  1. AtomicInteger: 用于对整数进行原子操作,支持原子的自增(incrementAndGet())、自减(decrementAndGet())等操作。

  2. AtomicLong: 用于对长整型进行原子操作,同样支持原子的自增、自减等操作。

  3. AtomicBoolean: 用于对布尔类型进行原子操作,支持原子的设置和获取操作。

  4. AtomicReference: 用于对引用类型进行原子操作,支持原子的获取和设置引用对象。

  5. AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray: 用于对数组中的元素进行原子操作,提供了一些原子性的数组操作。

  6. AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater: 用于对类的字段进行原子更新,允许在并发环境中对对象的字段进行原子性操作。

这些原子类提供了一种比使用 synchronized 关键字更轻量级的线程安全机制,特别适用于一些简单的计数器、状态标志等场景。在需要进行原子操作而又不需要全局的锁的情况下,这些类可以提供更好的性能。

虽然这些类提供了原子性的操作,但并不是所有的操作都可以用原子方式完成,因此在使用时仍然需要注意保证原子性的操作是否符合预期。