一、Java基础知识
1、面向对象的特征
2、Java 的基本数据类型有哪些
3、JDK JRE JVM 的区别
4、重载和重写的区别
5、Java中==和equals的区别
6 、String、StringBuffer、StringBuilder三者之间的区别
7、接口和抽象类的区别是什么?
8、反射
9、jdk1.8 的新特性(高薪常问)
9.1、Lambda 表达式
9.2、方法引用
9.3、函数式接口
9.4、接口允许定义默认方法和静态方法
9.5、Stream API
9.6、日期/时间类改进
9.7、Optional 类
9.8、Java8 Base64 实现
10、Java 的异常
11、BIO、NIO、AIO 有什么区别?(高薪常问)
12、Threadloal 的原理(高薪常问)
13、同步锁、死锁、乐观锁、悲观锁 (高薪常问)
14、说一下 synchronized 底层实现原理?(高薪常问)
15、synchronized 和 volatile 的区别是什么?(高薪常问)
16、synchronized 和 Lock 有什么区别? (高薪常问)
二、集合
1、常见的数据结构
2、集合和数组的区别
3、List 和 Map、Set 的区别
4、 List 和 Map、Set 的实现类
4.1、Connection 接口
4.2、Map 接口有四个实现类
5、Hashmap 的底层原理
6、Hashmap 和 hashtable ConcurrentHashMap 区别
一、Java基础知识
1、面向对象的特征
面向对象的特征:封装、继承、多态、抽象。封装:封装是将数据和操作(方法)捆绑在一起的机制,使得对象的内部细节对外部是隐藏的,外部只能通过对象的公共接口来访问和操作对象。封装提供了信息隐藏和保护数据的功能,使得对象的使用更加简单和安全。继承:继承是一种机制,允许一个类(子类)继承另一个类(父类)的属性和方法。子类可以重用父类的代码,并且可以在此基础上扩展或修改功能。通过继承,可以实现代码的重用性和扩展性,减少代码的重复编写。多态:多态是指相同的操作或方法在不同的对象上可以有不同的行为。在面向对象编程中,多态可以通过继承和方法重写来实现。多态提高了代码的灵活性和可扩展性,使得程序可以根据实际情况选择合适的行为。抽象:抽象是指将复杂的现实世界映射到程序设计中的简化模型,只关注对象的重要特征和行为,忽略不必要的细节。抽象可以通过类和接口来实现,在面向对象编程中,通过抽象可以隐藏对象的复杂性,提高程序的可理解性和可维护性。
2、Java 的基本数据类型有哪些
3、JDK JRE JVM 的区别
JDK(Java Development Kit)是整个Java 的核心,是java开发工具包,包括了Java 运行环境JRE、Java 工具和Java 基础类库。JRE(Java Runtime Environment)是运行JAVA 程序所必须的环境的集合,包含java虚拟机和java程序的一些核心类库。JVM 是Java Virtual Machine(Java 虚拟机)的缩写,是整个java 实现跨平台的最核心的部分,能够运行以Java 语言写作的软件程序。
4、重载和重写的区别
重载:发生在同一个类中,方法名必须相同,参数类型不同.个数不同.顺序不同,方法返回值和访问修饰符可以不同,发生在编译时。
重写:发生在父子类中,方法名.参数列表必须相同,返回值范围小于等于父类,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类;如果父类方法访问修饰符为private则子类就不能重写该方法。
5、Java中==和equals的区别
== 的作用:
基本数据类型,==
比较的是值是否相等引用类型(对象),
==
比较的是引用地址是否相同equals 的作用 :引用类型:默认情况下,比较的是地址值。特:String、Integer、Date这些类库中equals被重写,比较的是内容而不是地址!
请解释字符串比较之中“ == ”和equals() 的区别?
答:==:比较的是两个字符串内存地址(堆内存)的数值是否相等,属于数值比较; equals():比较的是两个字符串的内容,属于内容比较。
6 、String、StringBuffer、StringBuilder三者之间的区别
String 字符串常量
StringBuffer 字符串变量(线程安全)
StringBuilder 字符串变量(非线程安全)
String 中的String 类中使用final 关键字修饰字符数组来保存字符串,private final char value[] ,String对象是不可变的,也就可以理解为常量,线程安全。
AbstractStringBuilder 是StringBuilder 与StringBuffer 的公共父类,定义了一些字符串的基本操作,如expandCapacity、append、insert、indexOf 等公共方法。
StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
总结:(1)如果要操作少量的数据用String;(2)多线程操作字符串缓冲区下操作大量数据用StringBuffer;(3)单线程操作字符串缓冲区下操作大量数据用StringBuilder。
7、接口和抽象类的区别是什么?
实现:抽象类的子类使用extends 来继承;接口必须使用implements 来实现接口。
构造函数:抽象类可以有构造函数;接口不能有。main 方法:抽象类可以有 main 方法,并且我们能运行它;接口不能有 main 方法。实现数量:类可以实现很多个接口;但是只能继承一个抽象类。访问修饰符:接口中的方法默认使用 public 修饰;抽象类中的方法可以是任意访问修饰符
8、反射
在 Java 中的反射机制是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法;并且对于任意一个对象,都能够调用它的任意一个方法;这种动态获取信息 以及动态调用对象方法的功能成为 Java 语言的反射机制。获取 Class 对象的 3 种方法 :调用某个对象的 getClass()方法Person p=new Person();Class clazz=p.getClass();调用某个类的 class 属性来获取该类对应的 Class 对象Class clazz=Person.class;使用 Class 类中的 forName()静态方法(最安全/性能最好)Class clazz=Class.forName("类的全路径"); (最常用)
9、jdk1.8 的新特性(高薪常问)
9.1、Lambda 表达式
Lambda 允许把函数作为一个方法的参数。
9.2、方法引用
方法引用允许直接引用已有 Java 类或对象的方法或构造方法上例中我们将 System.out::println 方法作为静态方法来引用。
9.3、函数式接口
有且仅有一个抽象方法的接口叫做函数式接口,函数式接口可以被隐式转换为 Lambda 表达式。通常函数式接口上会添加@FunctionalInterface 注解。
9.4、接口允许定义默认方法和静态方法
从 JDK8 开始,允许接口中存在一个或多个默认非抽象方法和静态方法
9.5、Stream API
新添加的 Stream API(java.util.stream)把真正的函数式编程风格引入到 Java 中。这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理, 比如筛选,排序,聚合等。
9.6、日期/时间类改进
之前的 JDK 自带的日期处理类非常不方便,我们处理的时候经常是使用的第三方工具包,比如 commons-lang 包等。不过 JDK8 出现之后这个改观了很多,比如日期时间的创建、比较、调整、格式化、时间间隔等。这些类都在 java.time 包下,LocalDate/LocalTime/LocalDateTime
9.7、Optional 类
Optional 类是一个可以为 null 的容器对象。如果值存在则 isPresent()方法会返回 true,调用 get()方法会返回该对象。
9.8、Java8 Base64 实现
Java 8 内置了 Base64 编码的编码器和解码器。
10、Java 的异常
Throwable 是所有 Java 程序中错误处理的父类,有两种资类:Error 和 Exception。Error:表示由 JVM 所侦测到的无法预期的错误,由于这是属于 JVM 层次的严重错误,导致 JVM 无法继续执行,因此,这是不可捕捉到的,无法采取任何恢复的操作,顶多只能显示错误信息。Exception:表示可恢复的例外,这是可捕捉到的。1.运行时异常:都是 RuntimeException 类及其子类异常,如 NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。运行时异常的特点是 Java 编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用 try-catch 语句捕获它,也没有用 throws子句声明抛出它,也会编译通过。2.非运行时异常(编译异常):是 RuntimeException 以外的异常,类型上都属于 Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如 IOException、SQLException 等以及用户自定义的 Exception 异常,一般情况下不自定义检查异常。常见的 RunTime 异常几种如下:NullPointerException - 空指针引用异常ClassCastException - 类型强制转换异常。IllegalArgumentException - 传递非法参数异常。ArithmeticException - 算术运算异常ArrayStoreException - 向数组中存放与声明类型不兼容对象异常IndexOutOfBoundsException - 下标越界异常NegativeArraySizeException - 创建一个大小为负数的数组错误异常NumberFormatException - 数字格式异常SecurityException - 安全异常UnsupportedOperationException - 不支持的操作异常
11、BIO、NIO、AIO 有什么区别?(高薪常问)
BIO:Block IO 同步阻塞式 IO,就是我们平常使用的传统 IO,它的特点是模式简单使用方便,并发处理能力低。NIO:New IO 同步非阻塞 IO,是传统 IO 的升级,客户端和服务器端通过 Channel(通道)通讯,实现了多路复用。AIO:Asynchronous IO 是 NIO 的升级,也叫 NIO2,实现了异步非堵塞 IO ,异步 IO的操作基于事件和回调机制。
12、Threadloal 的原理(高薪常问)
ThreadLocal:为共享变量在每个线程中创建一个副本,每个线程都可以访问自己内部的副本变量。通过 threadlocal 保证线程的安全性。其实在 ThreadLocal 类中有一个静态内部类 ThreadLocalMap(其类似于 Map),用键值对的形式存储每一个线程的变量副本,ThreadLocalMap 中元素的 key 为当前 ThreadLocal对象,而 value 对应线程的变量副本。ThreadLocal 本身并不存储值,它只是作为一个 key 保存到 ThreadLocalMap 中,但是这里要注意的是它作为一个 key 用的是弱引用,因为没有强引用链, 弱引用在 GC 的时候可能会被回收。这样就会在 ThreadLocalMap 中存在一些 key 为 null 的键值对(Entry)。因为 key 变成 null 了,我们是没法访问这些 Entry 的,但是这些 Entry 本身是不会被清除的。如果没有手动删除对应 key 就会导致这块内存即不会回收也无法访问,也就是 内存泄漏。使用完 ThreadLocal 之后,记得调用 remove 方法。 在不使用线程池的前提下,即使不调用 remove 方法,线程的"变量副本"也会被 gc 回收,即不会造成内存泄漏的情况。
13、同步锁、死锁、乐观锁、悲观锁 (高薪常问)
同步锁:当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。Java 中可以使用 synchronized 关键字来取得一个对象的同步锁。死锁:何为死锁,就是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS 算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_conditio 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现
14、说一下 synchronized 底层实现原理?(高薪常问)
1、syn 锁住的是对象,对象里边的组成如下:Java 的对象布局必须是要求对象的大小必须是 8 的整数倍
- 首先是有一个 object header
- 填充数据 :当对象大小不足 8 的倍数的时候,他会把当前对象填充成 8 的 倍数,假如他现在本身就是 8 的 byte 的倍数,此时填充数据就不用了
- 成员变量 :就是咱们成员变量2)锁升级
(1) 偏向锁线程要尝试去加锁,他会去判断当前这个 mark-word 里边是否包含线程 id,如果没有线程id 的话,他会去利用 cas 把自己的线程 id 写入到 mark-word 里边去第二次这个线程再次过来的时候,他会去判断当前这个 mark-word 里边是否包含线程 id,如果有了的话,他就会把他自己的线程 id 和对象头里边的线程 id 进行对比,如果发现是一样的此时就标识获得到了锁如果你在没加锁的情况打印对象头:他默认就是 无锁可偏向,如果你没加锁的情况计算了hashCode 码,无锁不可偏向,如果此时你加锁无法成为偏向锁,直接膨胀成一把轻量级锁(2)轻量级锁
- 升级成轻量级锁三个条件
你现在已经是无锁不可偏向,此时加锁那么他就直接是一把轻量级锁没有关闭延迟偏向锁打开,他会自动成为轻量级锁如果程序出现交替执行,他也会成为一把轻量级锁
- 原理
首先方法压栈,此时这个方法栈帧就压栈,栈帧种就创建两个和锁有关系的空间displace hrd ,owner他会将锁里边的 mark-word 里边信息拷贝到 hrd 种他会用栈帧 owner 指针去指向我们的对象头对象头中轻量级指针会指向当前创建出来的这个栈帧 锁会把当前的状态修改成 00如果说完成了以上 4 件事情,那么此时才表示加锁成功,这把锁就是属于当前线程的(3) 重量级锁 原理
- Java 如果发现要创建一把重量级锁,我们 Java 就会为我们创建一个 C++的 ObjectMonitor,会让对象头中 monitor 指向我们这个 ObjectMonitor 对象如果你进入到 锁内部时,这个 ObjectMonitor 他会 发起汇编指定 monitorenter,当你出 syn 代码代码 块的时候,他会发出 monitorexit 指令如果你在执行 syn 过程中出现了异常,其实上他还 是会执行 monitorexit 这样一个指令
- 对象头里边 monitor 指针会指向 ObjectMonitor 对象,当多个线程来加锁的 时候他,他们就会执行 monitorenter 指令,进入到 ObjectMonitor 进入到 entrylist 中 等待抢锁,他们会利用 cas 来进行抢锁,如果抢锁成功,ObjectMonitor,他会把他内部 的 owner 的指针去指向咱们抢锁成功 线程,然后会让计数器+1,如果此时是抢锁的线程 是持有锁的线程,那么此时 count 就会再+1 ,释放锁的时候,把 count 进行--,直到 count== 0 的时候就把 owner 置为 null,如果你对线程调用 wait 方法,此时这些被 wait 的线程他就会进入到 waitSet 中,只有当你去调用 notifyall 方法的时候他才会从新开始这 样一套流程
- 如果是同步方法,那么他执行的 指令 acc_synchronized ,但是这哥们是隐式 调用
15、synchronized 和 volatile 的区别是什么?(高薪常问)
volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的。volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
16、synchronized 和 Lock 有什么区别? (高薪常问)
首先 synchronized 是 java 内置关键字,在 javjvm 层面,Lock 是个a 类;synchronized 无法判断是否获取锁的状态,Lock 可以判断是否获取到锁;synchronized 会自动释放锁(a 线程执行完同步代码会释放锁;b 线程执行过程中发生异常会释放锁),Lock 需在 finally 中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁;用 synchronized 关键字的两个线程 1 和线程 2,如果当前线程 1 获得锁,线程 2 线程等待。如果线程 1 阻塞,线程 2 则会一直等待下去,而 Lock 锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;synchronized 的锁可重入、不可中断、非公平,而 Lock 锁可重入、可判断、可公平(两者皆可);Lock 锁适合大量同步的代码的同步问题,synchronized 锁适合代码少量的同步问题。
二、集合
1、常见的数据结构
数组是最常用的数据结构,数组的特点是长度固定,数组的大小固定后就无法扩容了 ,数组只能存储一种类型的数据 ,添加,删除的操作慢,因为要移动其他的元素。栈是一种基于先进后出(FILO)的数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。队列是一种基于先进先出(FIFO)的数据结构,是一种只能在一端进行插入,在另一端进行删除操作的特殊线性表,它按照先进先出的原则存储数据,先进入的数据,在读取数据时先被读取出来。链表是一种物理存储单元上非连续、非顺序的存储结构,其物理结构不能只表示数据元素的逻辑顺序,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列的结节(链表中的每一个元素称为结点)组成,结点可以在运行时动态生成。根据指针的指向,链表能形成不同的结构,例如单链表,双向链表,循环链表等。树是我们计算机中非常重要的一种数据结构,同时使用树这种数据结构,可以描述现实生活中的很多事物,例如家谱、单位的组织架构等等。有二叉树、平衡树、红黑树、B 树、B+树。散列表,也叫哈希表,是根据关键码和值 (key 和 value) 直接进行访问的数据结构,通过 key 和 value 来映射到集合中的一个位置,这样就可以很快找到集合中的对应元素。堆是计算机学科中一类特殊的数据结构的统称,堆通常可以被看作是一棵完全二叉树的数组对象。图的定义:图是由一组顶点和一组能够将两个顶点相连的边组成的
2、集合和数组的区别
区别:数组长度固定 集合长度可变 数组中存储的是同一种数据类型的元素,可以存储基本数据类型,也可以存储引用数据类型;集合存储的都是对象,而且对象的数据类型可以不一致。在开发当中一般当对象较多的时候,使用集合来存储对象。
3、List 和 Map、Set 的区别
List 和 Set 是存储单列数据的集合,Map 是存储键值对这样的双列数据的集合;List 中存储的数据是有顺序的,并且值允许重复;Map 中存储的数据是无序的,它的键是不允许重复的,但是值是允许重复的;Set 中存储的数据是无顺序的,并且不允许重复,但元素在集合中的位置是由元素的hashcode 决定,即位置是固定的(Set 集合是根据 hashcode 来进行数据存储的,所以位置是固定的,但是这个位置不是用户可以控制的,所以对于用户来说 set 中的元素还是无序的)。
4、 List 和 Map、Set 的实现类
4.1、Connection 接口
List 有序,可重复ArrayList优点: 底层数据结构是数组,查询快,增删慢。缺点: 线程不安全,效率高Vector优点: 底层数据结构是数组,查询快,增删慢。缺点: 线程安全,效率低, 已给舍弃了LinkedList优点: 底层数据结构是链表,查询慢,增删快。缺点: 线程不安全,效率高Set 无序,唯一HashSet底层数据结构是哈希表。(无序,唯一)如何来保证元素唯一性?依赖两个方法:hashCode()和 equals()LinkedHashSet底层数据结构是链表和哈希表。(FIFO 插入有序,唯一)a.由链表保证元素有序b.由哈希表保证元素唯一TreeSet底层数据结构是红黑树。(唯一,有序)a. 如何保证元素排序的呢?自然排序比较器排序b.如何保证元素唯一性的呢?根据比较的返回值是否是 0 来决定
4.2、Map 接口有四个实现类
HashMap基于 hash 表的 Map 接口实现,非线程安全,高效,支持 null 值和 null 键, 线程不安全。HashTable线程安全,低效,不支持 null 值和 null 键;LinkedHashMap线程不安全,是 HashMap 的一个子类,保存了记录的插入顺序;TreeMap能够把它保存的记录根据键排序,默认是键值的升序排序,线程不安全。
5、Hashmap 的底层原理
HashMap 在 JDK1.8 之前的实现方式 数组+链表,但是在 JDK1.8 后对 HashMap 进行了底层优化,改为了由 数组+链表或者数值+红黑树实现,主要的目的是提高查找效率
(1)Jdk8 数组+链表或者数组+红黑树实现,当链表中的元素超过了 8 个以后, 会将链表转换为红黑树,当红黑树节点 小于 等于 6 时又会退化为链表。(2)当 new HashMap():底层没有创建数组,首次调用 put()方法示时,底层创建长度为16 的数组,jdk8 底层的数组是:Node[],而非 Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用 rehash 方法将数组容量增加到原来的两倍,专业术语叫做扩容,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能.默认的负载因子大小为 0.75,数组大小为 16。也就是说,默认情况下,那么当 HashMap中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍。(3)在我们 Java 中任何对象都有 hashcode,hash 算法就是通过 hashcode 与自己进行向右位移 16 的异或运算。这样做是为了计算出来的 hash 值足够随机,足够分散,还有产生的数组下标足够随机,map.put(k,v)实现原理1)首先将 k,v 封装到 Node 对象当中(节点)。2)先调用 k 的 hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。3)下标位置上如果没有任何元素,就把 Node 添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着 k 和链表上每个节点的 k 进行 equal。如果所有的 equals方法返回都是 false,那么这个新的节点将被添加到链表的末尾。如其中有一个 equals返回了 true,那么这个节点的 value 将会被覆盖。map.get(k)实现原理1)、先调用 k 的 hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。2)、在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回 null。如果这个位置上有单向链表,那么它就会拿着参数 K 和单向链表上的每一个节点的 K 进行 equals,如果所有 equals 方法都返回 false,则 get 方法返回 null。如果其中一个节点的 K 和参数 K 进行 equals 返回 true,那么此时该节点的 value 就是我们要找的 value 了,get 方法最终返回这个要找的 value。(4)Hash 冲突不同的对象算出来的数组下标是相同的这样就会产生 hash 冲突,当单线链表达到一定长度后效率会非常低。(5)在链表长度大于 8 的时候,将链表就会变成红黑树,提高查询的效率。
6、Hashmap 和 hashtable ConcurrentHashMap 区别
区别对比一(HashMap 和 HashTable 区别):(1)HashMap 是非线程安全的,HashTable 是线程安全的。(2)HashMap 的键和值都允许有 null 值存在,而 HashTable 则不行。(3)因为线程安全的问题,HashMap 效率比 HashTable 的要高。(4)Hashtable 是同步的,而 HashMap 不是。因此,HashMap 更适合于单线程环境,而 Hashtable 适合于多线程环境。一般现在不建议用 HashTable, ①是 HashTable是遗留类,内部实现很多没优化和冗余。②即使在多线程环境下,现在也有同步的ConcurrentHashMap 替代,没有必要因为是多线程而用 HashTable。区别对比二(HashTable 和 ConcurrentHashMap 区别):HashTable 使用的是 Synchronized 关键字修饰,ConcurrentHashMap 是 JDK1.7使用了锁分段技术来保证线程安全的。JDK1.8ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。