javase面试-深入源码

javase源码详解

1.==和equal()和hashcode

==和equals函数对于基本类型来说, = = 比较是值,equals不能比较基本类型

对于包装类型来说,== 比较的是对象的引用,就是对象的内存地址。而equals()通常被重写以比较对象的值。

需要注意的是,像Interger这种包装类具有缓存机制,如果在缓存的范围,==的结果可能就是true,因为他们都是指向常量池的同一个对象

对于引用类型来说,==比较的是其对象的内存地址,equals要分为两个情况,看这个类型到底重写了equals函数了没,重写了就按重写的比较,比如String类型,他的equals就是比较的对象的值。然后没有重写的话,equals内部还是使用 ==来比较。没有什么区别。还是比较的对象的内存地址

hashcode函数的作用是获取哈希码,然后确定该对象再hash表中的位置,比如hashmap,hashset,布隆过滤器等都用到了hashcode

hasecode分为好几种哈希函数,有取模的,有进行位运算的。我们在布隆过滤器中使用最好是使用两种hash函数来确定位的位置。

hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:ObjecthashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的

然后hashcode相同,equals不一定相同。hashcode不相同,这个对象一定不相同。这样的话,我们可以将hashcode和equals函数相结合。我们先用hash函数来判断,然后再用equals判断

如果这个对象真的不相同的话,那我们就可以用运行速度快的hash函数早判断。然后出现hash碰撞的时候我们再用equals函数来确定是不是真的相同。这样大大提高了效率,因为hash函数的运算是比equals快的。这也是hashmap,hashset,布隆过滤器的设计原理。

因此,我们在重写equals的时候,hashcode也必须重写,否则就会出现equals相等,但是hash不相同。就会出现重复值的问题

2.BigDecimial处理精度丢失的问题

BigDecimal是Java中用于处理高精度数值计算的类,尤其适用于金融、科学计算等对精度要求极高的场景。

最关键的就是两个字段:

  • intVal: 一个BigInteger对象,用于存储数值的非标度值 (unscaled value)。简单来说,就是去掉小数点后的整数值。
  • scale: 一个int类型的整数,表示标度 (scale)。标度指的是小数点后的位数。例如,对于数值 123.45,intVal是12345,scale是2。

BigDecimal 使用整数来表示数值,避免了浮点数的二进制表示法引入的精度问题。 它通过scale来记录小数点的位置,从而实现对小数的精确表示。

BigDecimal的运算都是基于BigInterger来实现的

  • 加减法的时候,调整两个数的sacle,对齐标度,然后将intval相加减,最好创建一个新的BigDecial对象,intval为相加减的结果,scale为调整后的标度

  • 乘法,将两个数的intval想乘,然后scale为两个BigDecimal的scale的和

  • 除法是最复杂的操作,因为可能产生无限循环小数,BigDecimal需要提供多种舍入模式 (RoundingMode) 来控制精度,比如

    • ROUND_UP: 向上舍入

    • ROUND_DOWN: 向下舍入

    • ROUND_CEILING: 向正无穷方向舍入

    • ROUND_FLOOR: 向负无穷方向舍入

    • ROUND_HALF_UP: 四舍五入 (大于等于0.5向上舍入)

    • ROUND_HALF_DOWN: 五舍六入 (大于0.5向上舍入)

    • ROUND_HALF_EVEN: 银行家舍入 (四舍六入,五看奇偶,偶舍奇入)BigDecimal会根据指定的舍入模式,计算出精确的结果,并截断到指定的精度。

但是会出现很多个BigDecimal对象:Bigdecimal 是一个immutable类,每次计算都会new一个新的对象。如果在一个循环内多次使用bigdecimal,会生成很多对象,影响性能,建议如果在循化内不要使用string 构造出bigdecimal, 否则生成大量的string对象和bigdecimal对象

3.变量

成员变量&&局部变量对比—变量存储的内存地址对应的任意随机值

  • 定义:成员变量是属于类的,局部变量是在代码块或者方法之中的
  • 存储:成员变量如果是使用static的话,那这个成员变量属于类,没有的话,在堆。局部变量在栈,栈之中维护了一个局部变量表
  • 生存时间:成员变量是对象的一部分,跟对象的生命周期一样,局部变量跟他的方法的生命周期一样
  • 默认值:成员变量没有被赋值的话,一般都会是类型的默认值,除非是final修饰的,必须显示的赋值,局部变量不赋值会报错。
特性 成员变量 (Instance Variable) 成员变量 (Static Variable) 局部变量 (Local Variable)
定义 属于类的属性,在类中方法外定义。 属于类的静态属性,在类中方法外定义,用static修饰。 在方法、代码块(如iffor语句内部)中定义的变量。
存储 存储在堆内存(Heap)中,作为对象的一部分。 存储在方法区(Method Area)或元空间(Metaspace)中。(JDK8+之后静态变量从方法区移动到了堆中,但逻辑概念上仍与类相关联) 存储在Java虚拟机栈(Java Virtual Machine Stack)的栈帧(Stack Frame)的局部变量表中。
生命周期 随着对象的创建而创建,随着对象的销毁而销毁。 随着类的加载而创建,随着类的卸载而销毁。(实际上与类的 Class 对象关联) 随着方法的调用而创建,随着方法的执行结束而销毁。
默认值 存在默认值。如果没有显式赋值,会赋予类型的默认值(如int为0,booleanfalseObjectnull)。 static 变量在类加载的准备阶段就会赋默认值. 如果没有显式赋值,会赋予类型的默认值(如int为0,booleanfalseObjectnull)。 不存在默认值。必须显式赋值后才能使用,否则编译报错。
final修饰 final修饰的成员变量必须在对象创建前(构造器或声明时)显式赋值,之后不能修改。 final static修饰的成员变量必须在类加载完成前(静态代码块或声明时)显式赋值,之后不能修改。 final修饰的局部变量必须在使用前显式赋值,之后不能修改。
线程安全 线程不安全,每个对象都有一份独立的成员变量副本,如果多个线程修改同一个对象的成员变量,可能导致数据不一致。 线程安全,所有该类的对象共享同一个静态变量,需要进行同步处理才能保证线程安全。 线程安全,局部变量只在当前线程的栈帧中有效,不同线程之间互不影响。

4.String家族三位

String家族的三位分别是String StringBuffer StringBulider,除去String,剩下的两个都是继承自AbstractStringBuilder

其中String是不可变的,StringBuffer和StringBulider是可变的,他们都有append等方法来操作字符串

不同的是StringBuffer是线程安全的,通过同步锁加到方法上,可以多线程操作,而StringBulider是线程不安全的,一般单线程操作。因此StringBulider的性能是最高的

那么为什么String是不可变的呢?

String类中使用final来修饰字符串数组来,导致他的引用类型不能再指向其他的对象,并且数组的私有的。并且没有提供暴露这个字符串的方法

final导致String不能被继承,进而避免了子类破坏String

字符串拼接使用什么?变量少的时候使用+,然后变量多的时候使用StringBulider,防止在循环中使用,建立多个StringBulider对象

在我们JVM的堆中,存在一个字符串常量池,主要就是为了避免字符串的重复的问题

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

我们去new一个新的字符串的时候,会看字符串常量池有没有这个字符串,有的话,直接返回该字符串的引用。没有的话,JVM会在常量池中创建该字符,然后返回他的引用,也就是说我们新建了两个对象

5.常见的IO&&拷贝

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。

我们的用户进程想要进行IO操作的话,必须通过系统调用来访问内核空间,也就是拷贝,从用户态转变为内核态进行拷贝

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

常见的IO模型:

同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O异步 I/O

在我们的java中,有三种常见的IO

BIO:属于同步堵塞的IO,同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。在客户端连接数量不高的情况下,是没问题的。但是高了就没办法了

NIO:Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。

同步非阻塞 IO,发起一个 read 调用,如果数据没有准备好,这个时候应用程序可以不阻塞等待,而是切换去做一些小的计算任务,然后很快回来继续发起 read 调用,也就是轮询。这个
轮询不是持续不断发起的,会有间隙, 这个间隙的利用就是同步非阻塞 IO 比同步阻塞 IO 高效的地方。

但是这样有问题的,程序需要不断进行IO系统轮询来判断是不是准备好了,然后就出现了我的IO多路复用

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。

目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。

  • 线程通过selectpollepoll等系统调用,监听多个文件描述符(File Descriptor, FD),一旦某个FD就绪(可读、可写),就通知应用程序。

  • select 调用:最大连接数有限制(通常是1024),由FD_SETSIZE决定。每次调用都需要将FD集合从用户空间拷贝到内核空间,开销大。内核采用轮询方式检查FD是否就绪,效率低。

  • poll调用:取消了最大连接数的限制。同样需要将FD集合拷贝到内核空间。
  • epoll 调用:基于事件驱动,只关注就绪的FD,避免了无意义的轮询。采用红黑树存储FD,查找效率高。使用mmap技术,减少了用户空间和内核空间之间的数据拷贝。

IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。

而我们java中的NIO,最重要的三个组件,Selector ,Buffer,Channel

通过多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。然后数据通过Channel让客户端将数据写入到Buffer中去

  • Channel: 代表一个连接通道,负责数据的读写。 Channel 类似于传统 I/O 中的流 (Stream),但更加灵活,可以进行双向数据传输。
  • Buffer: 缓冲区,用于存储数据。 NIO 使用缓冲区来读写数据,而不是直接操作流。 Java NIO 支持多种类型的缓冲区,例如 ByteBufferCharBufferIntBuffer 等。
  • Selector: 多路复用器,用于监听多个Channel的事件。 一个 Selector 可以同时监听多个 Channel 的连接、读、写等事件。 通过 Selector, 只需要一个线程即可管理多个 Channel,实现高效的 I/O 多路复用。
  • Reactor模式和Proactor模式: 是两种常用的并发编程模式,分别对应I/O多路复用和异步I/O。 Netty 采用了 Reactor模式。

AIO:

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

然后我们需要数据进行传输的时候,就需要对数据进行拷贝。比如用户进程在从硬盘里传输数据的时候,需要从用户态转为内核态然后才能进行拷贝。这样的话,效率比较慢,然后我们就出现了零拷贝技术

传统的数据传输流程中,用户数据通常会经过如下多次拷贝:

1
硬盘 → 内核缓冲区 → 用户态 → Socket 缓冲区 → 网卡

一般来说文件拷贝是要拷贝四次的,

当用户进程调用read(),用户态无法调用内核态的设备,只能触发系统调用(IO)。这时计算机需要从用户态切换为内核态。

到达内核态之后,计算机通过DMA控制器将数据从磁盘读取出来,放到内核的缓冲区。完成第一次拷贝。

CPU需要将缓冲区的数据拷贝到用户态的缓冲区,完成第二次拷贝,也是read()函数的返回。这时计算器需要从内核态切换为用户态。

因为最终的数据需要通过网卡输出,所以用户进程就需要调用write()函数,CPU将用户缓冲区的数据拷贝到Socket缓冲区,完成第三次拷贝。同时需要再次触发系统调用。这时计算机又需要从用户态切换为内核态。

DMA控制器把数据从Socket缓冲区,拷贝到网卡设备输出,至此完成第四次拷贝。同时需要将内核态切换为用户态,write()函数返回。

而“零拷贝”技术通过内核优化和 API 支持,能避免数据在用户态与内核态间的多次拷贝,从而提升性能。常用技术:

技术 说明
mmap 将文件映射到内存地址空间,避免文件拷贝
sendfile 直接将文件从磁盘发送到 Socket,避免数据进入用户态
writev 批量写入多个内存区域,减少系统调用
DirectByteBuffer(Java NIO) Java 堆外内存,提高 I/O 性能

mmap

  • mmap将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一映射关系。应用程序可以直接读写映射的内存区域,而不需要进行显式的readwrite系统调用
  • 原理: mmap 减少了数据在内核空间和用户空间之间的拷贝。 只需要从磁盘拷贝到内核缓冲区,然后用户进程直接从内核缓冲区读取数据,而无需再拷贝到用户空间。
  • 适用场景: 适用于需要频繁读写同一文件的场景,例如大型数据库、共享内存等。
  • 存在的问题:
    • mmap 对文件读写仍然需要两次上下文切换。
    • 如果多个进程同时对同一文件进行mmap映射,可能会导致数据不一致的问题。
  • 使用场景: 常用于读取静态资源。

sendfile()

sendfile() 系统调用允许将数据从一个文件描述符 (例如, 文件) 直接传输到另一个文件描述符 (例如, Socket)。 避免了数据在用户空间和内核空间之间的拷贝。

  1. 用户进程调用 sendfile() 系统调用, 指定输入和输出文件描述符。
  2. 数据通过 DMA 从磁盘读取到内核缓冲区。
  3. 数据直接从内核缓冲区拷贝到 Socket 缓冲区,或者更优的方式是:只有描述符信息从内核缓冲区拷贝到socket缓冲区。
  4. 数据通过 DMA 从 Socket 缓冲区传输到网卡。

静态文件服务器(例如 Nginx)通常使用 sendfile() 来将静态文件发送给客户端。只能适用于数据从文件传输到Socket的场景,范围有限

splice() (管道):

splice() 系统调用允许在两个文件描述符之间移动数据,而不需要在用户空间和内核空间之间进行复制。

  1. 创建两个管道(pipe)对象
  2. 调用 splice() 系统调用,将数据从输入文件描述符读取到第一个管道.
  3. 调用 splice() 系统调用,将数据从管道数据写到socket 。

适用于需要数据传输与转换(类似于Linux的管道操作)的场景

Direct I/O

Direct I/O 允许用户进程绕过内核缓冲区 (Page Cache), 直接访问磁盘。

  1. 用户进程发起 Direct I/O 请求。
  2. 数据通过 DMA 直接从磁盘传输到用户进程的缓冲区。
  • 需要用户进程自己管理缓存,增加了开发的复杂性。
  • 可能影响系统的整体性能, 因为绕过了 Page Cache。 (Page Cache 可以缓存热点数据,提高访问速度)。

大型数据库(例如 Oracle)通常使用 Direct I/O 来进行数据读写, 因为数据库有自己的缓存管理机制。

DirectByteBuffer (Java NIO):

  • 是Java NIO 提供的一种堆外内存分配方式,它允许JVM直接在操作系统本地内存(堆外内存)中分配缓冲区,而不是在JVM堆中分配。
  • 避免了数据从JVM堆内存拷贝到直接内存 (Native memory) 的过程。
  • 适用场景: 适用于需要高效I/O的场景,例如网络服务器、大数据处理等。
  • 原理: DirectByteBuffer并不是真正意义上的零拷贝,因为它仍然需要在用户空间和内核空间之间进行数据拷贝。 但是,它可以减少一次数据拷贝,从而提高I/O性能。 通过调用操作系统的read方法,将数据从IO端口读取到这个直接内存。

好处:

  • 减少数据拷贝次数: 降低CPU的开销, 提高I/O效率。
  • 减少上下文切换次数: 降低系统开销, 提高并发能力。
  • 提高数据传输速度: 缩短响应时间, 提供更好的用户体验。

6.Java中的值传递

  • 值传递:方法接收的是实参值的拷贝,会创建副本。
  • 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。

但是在java中只有值传递

比如我们设定一个简单的swap方法,交换值得方法,num1=a num2=b

swap() 方法中,ab 的值进行交换,并不会影响到 num1num2。因为,ab 的值,只是从 num1num2 的复制过来的。也就是说,a、b 相当于 num1num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。

再比如

我们设定一个swap方法,交换两个Person参数

然后我们发现swap 方法的参数 person1person2 只是拷贝的实参 xiaoZhangxiaoLi 的地址。因此, person1person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhangxiaoLi

java值传参:

  • 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
  • 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。

7.序列化&&反序列化

  • 序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
  • 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程

序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。

下面是序列化和反序列化常见应用场景:

  • 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
  • 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
  • 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
  • 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化

OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。

然后我们有好几种序列化的方式,jdk自带的效率低且有安全问题,比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。

  • jdk自带的序列化方式,只需要实现Serializable接口即可,我们一般会加上一个私有静态final的变量,serialVersionUID。是类似于版本控制的效果,如果 serialVersionUID 不一致则会抛出 InvalidClassException 异常。强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的 serialVersionUIDserialVersionUID 是一个特例,serialVersionUID 的序列化做了特殊处理。关键在于,serialVersionUID 不是作为对象状态的一部分被序列化的,而是被序列化机制本身用作一个特殊的“指纹”或“版本号”

  • 对于我们不想进行序列化的变量,可以使用transient 关键字修饰。阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。除了serialVersionUID以外。

  • Kryo ,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。使用的时候也是需要实现Serializer接口,然后分别去重写serialize方法和deserialize方法
  • Protobuf,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。
  • Protostuff,protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。

这些反序列化的话,会有漏洞

许多序列化协议都存在反序列化漏洞,攻击者可以通过构造恶意的序列化数据,在反序列化过程中执行任意代码,从而控制目标系统。比如kryo

  • 防止反序列化漏洞的措施:
    • 避免使用存在已知漏洞的序列化协议。
    • 对序列化数据进行签名或加密,防止篡改。
    • 使用白名单机制,只允许反序列化特定类型的对象。
    • 限制反序列化的深度和复杂度,防止资源耗尽。

8.Unsafe解析

Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等

Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method),本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码

Unsafe 类为一单例实现,提供静态方法 getUnsafe 获取 Unsafe实例。这个看上去貌似可以用来获取 Unsafe 实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException 异常

这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。

为什么这个类这么严格?Unsafe 提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。

那我们该怎么去获取unsafe的实例呢?

  1. 利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe
1
2
3
4
5
6
7
8
9
10
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
  1. getUnsafe方法的使用限制条件出发,通过 Java 命令行命令-Xbootclasspath/a把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取 Unsafe 实例。

Unsafe的功能多种多样,比如内存操作,内存屏障,对象操作,数据操作,CAS 操作,线程调度,Class 操作,系统信息

  1. 内存操作:

在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:

1
2
3
4
5
6
7
8
9
10
//分配新的本地空间
public native long allocateMemory(long bytes);
//重新调整内存空间的大小
public native long reallocateMemory(long address, long bytes);
//将内存设置为指定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);
//清除内存
public native void freeMemory(long address);

通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。

那我们为什么要使用堆外内存?

对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。

提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。

比如:DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。类似实现了零拷贝的功能,但是其实他并没有实现零拷贝。创建 DirectByteBuffer 的时候,通过 Unsafe.allocateMemory 分配内存、Unsafe.setMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放

  1. 内存屏障:

编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。

比如我们的voliate关键词就是通过内存屏障,来保证了禁止重排。主要是就是保证读写的屏障

内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能

Unsafe 中提供了下面三个内存屏障相关方法:

1
2
3
4
5
6
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();

内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。

应用:

StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。

为了解决这个问题,StampedLockvalidate 方法会通过 UnsafeloadFence 方法加入一个 load 内存屏障。

  1. 对象操作:

对象成员属性的内存偏移量获取,以及字段属性值的修改

Unsafe 提供了全部 8 种基础数据类型以及Objectputget方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。

基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value

Unsafe 还提供了 volatile 读写有序写入方法。

volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。

有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。

顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类,而在volatile写入时加入的内存屏障是StoreLoad类型。

其中:

  • Load:将主内存中的数据拷贝到处理器的缓存中
  • Store:将处理器缓存的数据刷新到主内存中

在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。

  1. 对象实例化:

使用 UnsafeallocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为private类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但allocateInstance方法仍然有效。

比如:

new 机制有个特点就是当类只提供有参的构造函数且无显式声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。

Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用

5.数组操作:

arrayBaseOffsetarrayIndexScale 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。

1
2
3
4
//返回数组中第一个元素的偏移地址
public native int arrayBaseOffset(Class<?> arrayClass);
//返回数组中一个元素占用的大小
public native int arrayIndexScale(Class<?> arrayClass);

这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 UnsafearrayBaseOffsetarrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作

  1. CAS

CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg

Unsafe 类中,提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。以compareAndSwapInt方法为例:

如果 CAS 操作成功(返回 true),则打印 targetValue 并退出循环。

如果 CAS 操作失败,或者 currentValue 不满足条件,则当前线程会继续循环(自旋),并通过 Thread.yield() 尝试让出 CPU,直到成功更新并打印或者条件满足。

这样我们就可以通过CAS来实现多个线程1-9的顺序输出,a=0

线程1是 i<5的情况,线程2是i<10的情况

我们的CAS加的是(a,i-1,i);所以的话,线程一就是0-4的输出,线程2就是5-9.然后使用之前,我们要把获取unsafe实例和获取a字段的内存偏移量给静态加载进去

1
2
3
4
5
6
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
// 获取 a 字段的内存偏移量
fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));

  1. 线程调度

Unsafe 类中提供了parkunparkmonitorEntermonitorExittryMonitorEnter方法进行线程调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
//取消阻塞线程
public native void unpark(Object thread);
//阻塞线程
public native void park(boolean isAbsolute, long time);
//获得对象锁(可重入锁)
@Deprecated
public native void monitorEnter(Object o);
//释放对象锁
@Deprecated
public native void monitorExit(Object o);
//尝试获取对象锁
@Deprecated
public native boolean tryMonitorEnter(Object o);

方法 parkunpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常

Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用LockSupport.park()LockSupport.unpark()实现线程的阻塞和唤醒的,而 LockSupportparkunpark 方法实际是调用 Unsafeparkunpark 方式实现的

就是通过这个实现了CLH队列,减少了在等待队列里面消耗等待的时间

  1. Class操作

UnsafeClass的相关操作主要包括类加载和静态变量的操作方法。

1
2
3
4
5
6
//获取静态属性的偏移量
public native long staticFieldOffset(Field f);
//获取静态属性的对象指针
public native Object staticFieldBase(Field f);
//判断类是否需要初始化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized(Class<?> c);

Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class

9.SPI机制

10.语法糖

11.新特性(Java17&&Java21)

1.虚拟线程 JDK21

虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程(Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。

我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。

优点:

非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。

简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。

减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。

缺点:

不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。

与某些第三方库不兼容: 虽然虚拟线程设计时考虑了与现有代码的兼容性,但某些依赖平台线程特性的第三方库可能不完全兼容虚拟线程。

创建方法:

  1. 使用 Thread.startVirtualThread() 创建
  2. 使用 Thread.ofVirtual() 创建
1
2
3
4
5
6
CustomThread customThread = new CustomThread();
// 创建不启动
Thread unStarted = Thread.ofVirtual().unstarted(customThread);
unStarted.start();
// 创建直接启动
Thread.ofVirtual().start(customThread);
  1. 使用 ThreadFactory 创建
1
2
3
4
CustomThread customThread = new CustomThread();
ThreadFactory factory = Thread.ofVirtual().factory();
Thread thread = factory.newThread(customThread);
thread.start();
  1. 使用Executors.newVirtualThreadPerTaskExecutor()创建

12.源码详解ArrayList

ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。一开始先分配一个空间,然后后面快满了再进行扩容。list 列表的结尾会预留一定的容量空间

ArrayList 继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。

ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。

扩容机制的分析:

  • 默认的初始容量大小就是10,一般采用默认构造函数,创造一个空列表,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。

  • 带初始容量的构造参数,就使用用户指定的初始容量。>0就用指定的,=0就创建空数组,<0抛出异常

  • 构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回

扩容的参数是int newCapacity = oldCapacity + (oldCapacity >> 1)所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.

13.TreeMap

TreeMap它还实现了NavigableMap接口和SortedMap 接口。

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力

  • 定向搜索: ceilingEntry(), floorEntry(), higherEntry()lowerEntry() 等方法可以用于定位大于等于、小于等于、严格大于、严格小于给定键的最接近的键值对。

  • 子集操作: subMap(), headMap()tailMap() 方法可以高效地创建原集合的子集视图,而无需复制整个集合。

  • 逆序视图:descendingMap() 方法返回一个逆序的 NavigableMap 视图,使得可以反向迭代整个 TreeMap

  • 边界操作: firstEntry(), lastEntry(), pollFirstEntry()pollLastEntry() 等方法可以方便地访问和移除元素

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。

指定排序的比较器,重写compare方法

14.HashMap底层分析&问题解析

  1. 源码分析

HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。

通过 (n - 1) & hash 判断当前元素存放的位置,使用位运算的计算速度快,而且HashMap的的容量都是2的次幂,然后这样我们就可以使用位运算,最高位的一个标志。根据这个来确定是否在原位置,0不动,1+当前的长度

负载因子:一般都是0.75,这是一个经验值,loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。长度的默认为16

  1. HashMap 的长度为什么是 2 的幂次方?

位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,hash % length 等价于 hash & (length - 1)

可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。

扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。

  1. HashMap 多线程操作导致死循环问题

JDK1.7 之前的版本,在多线程下,使用头插法容易形成环形链表。JDK1.8 版本的 HashMap 采用了尾插法,避免了环形问题,但多线程下使用 HashMap 还是会存在数据覆盖的问题

  1. ConcurrentHashMap1.7 Segment 数组 + HashEntry 数组 + 链表

初始化逻辑:

  • 必要参数校验。

  • 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16.

  • 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16

  • 记录 segmentShift 偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.

  • 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.

  • 初始化 segments[0]默认大小为 2负载因子 0.75扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩

根据put计算到key的位置,获取指定的Segment,如果为空那么初始化Segment

  1. 检查计算得到的位置的 Segment 是否为 null.

    为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。

    再次检查计算得到的指定位置的 Segment 是否为 null.

    使用创建的 HashEntry 数组初始化这个 Segment.

    自旋判断计算得到的指定位置的 Segment 是否为 null,使用 CAS 在这个位置赋值为 Segment

  2. Segment.put 插入 key,value 值。

ConcurrentHashMap1.8 Node 数组 + 链表 / 红黑树

的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl (sizeControl 的缩写),它的值决定着当前的初始化状态。

  1. -1 说明正在初始化,其他线程需要自旋等待
  2. -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数
  3. 0 表示 table 初始化大小,如果 table 没有初始化
  4. >0 表示 table 扩容的阈值,如果 table 已经初始化

put方法:

根据 key 计算出 hashcode 。

判断是否需要进行初始化。

即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。

如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

如果都不满足,则利用 synchronized 锁写入数据。

如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。