Dawn's Blogs

分享技术 记录成长

0%

在 Java 7 以及之前,堆分为三个区域新生代(Eden、S0、S1)、老生代(Tenured)、永久代;但是从 Java 8 开始,永久代已经被元空间所代替,元空间使用的是直接内存。

垃圾回收

空间回收原则

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

  • 部分收集 (Partial GC):

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

JVM 内存分配与空间回收有以下几个原则:

  • 对象优先在 Eden 区分配。
  • 大对象直接进入老年代。
  • 长期存活的对象进入老年代。
  • 空间分配担保。

对象优先在 Eden 分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC,然后在 Eden 区分配。

大对象直接进入老年代

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本

  • G1 垃圾回收器根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。
  • Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。

长期存活的对象进入老年代

虚拟机给每一个对象一个年龄计数器,作为对象存活的年龄。大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半,则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。

空间分配担保

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

死亡对象判别

死亡对象判别常用方法有:应用计数法和可达性分析。

如何判断废弃常量?

如果没有任何一个 String 对象引用一个字符串常量,那么这个字符串常量就是废弃的。

如何判断无用的类?

判断无用的类,需要满足三个条件(虚拟机可以对满足上述 3 个条件的无用类进行回收,仅仅是可以,不是一定):

  1. 该类的所有实例被回收。
  2. 加载该类的 ClassLoader 被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

引用计数法

引用计数法:每一个对象有一个引用计数器,这个方法实现简单,效率高,但是没有办法解决循环引用问题。

可达性分析

可达性分析:GC ROOT 出发,找到所有可达对象;不可达对象就是需要被回收的。

哪些对象可以作为 GC Roots 呢?

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

引用类型

JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

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

  1. 强引用(StrongReference):类似于必不可少的生活用品,平时的引用就是强引用。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会回收具有强引用的对象来解决内存不足问题。
  2. 软引用(SoftReference):类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
  3. 弱引用(WeekReference):类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
  4. 虚引用(PhantomReference):虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动

虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

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

垃圾清扫算法

标记-清除

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

这种垃圾收集算法会带来两个明显的问题:

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

复制

它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。虽然改进了标记-清除算法,但依然存在下面这些问题:

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

标记-整理(标记-压缩)

标记-整理(压缩)算法是根据老年代的特点提出的一种算法,在回收时,让所有存活的对象向一端移动

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

分代收集

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

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

垃圾收集器

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1

Serial/Serial Old 收集器

Serial 负责新生代,Serial Old 负责老年代。二者都是单线程收集器,在垃圾回收的过程中需要 STW,直到垃圾收集结束。新生代采用标记-复制算法,老年代采用标记-整理算法。

优点:它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

Serial 收集器

ParNew 收集器

ParNew 是 Serial 收集器的多线程版本。新生代采用标记-复制算法,老年代采用标记-整理算法。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。

ParNew 收集器

Parallel Scavenge/Parallel Old 收集器

Parallel Scavenge 收集器是负责年轻代的垃圾回收,也是使用标记-复制算法的多线程收集器。Parallel Scavenge 收集器关注点是吞吐量,CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

Parallel Old 是 Parallel Scavenge 收集器的老年代版本。使用多线程和标记-整理算法。

Parallel Old收集器运行示意图

CMS 收集器

CMS 主要关注老年代的对象收集,ParNew 作为年轻代的对象收集配合使用。

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 收集器是一种标记-清除算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

CMS 在并行标记阶段使用三色标记法+插入和删除写屏障,保证黑色节点不会指向白色节点。

主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),在 JDK 14 被移除。

CMS 收集器

G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率。

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。

ZGC 收集器

ZGC 在 Java 11 引入,处于实验阶段;在 Java15 已经可以正式使用了;在 Java 21 中引入分代 ZGC。

当应用程序发起 IO 系统调用后,会经历两个阶段:

  • 内核等待 I/O 设备准备好数据。
  • 内核将数据从内核空间拷贝到用户空间。

BIO

BIO(Blocking IO)属于同步阻塞 IO,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。BIO 因为应用程序一直阻塞,直到系统调用返回,所以效率不高。

图源:《深入拆解Tomcat & Jetty》

NIO

NIO(Non-blocking/New IO),可以看作是多路复用 IO,Java 在 1.4 中引入 java.nio 包,提供了 Channel , SelectorBuffer 等抽象。

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。

img

NIO 核心组件

Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

Buffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。

Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。

Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。

Buffer、Channel和Selector三者之间的关系

类比于 Golang:

  • Channel 和 Buffer:可以看作是 channel 和 channel 的缓冲区。
  • Selecter:go select

Buffer

在 Java nio 库中,所有数据都是用缓冲区处理的。使用 NIO 在读写数据时,都是通过缓冲区进行操作。

Buffer 的子类如下图所示。其中,最常用的是 ByteBuffer,它可以用来存储和操作字节数据。

Buffer 的子类

Buffer 有四个成员变量:

  • 容量 capacity:Buffer 可以存储的最大容量,创建时设置且不可改变。
  • 界限 limit:Buffer 中可以读/写数据的边界。写模式下,limit 表示最多能写入的数据;读模式下,limit 表示最多能读取的数据。
  • 位置 position:下一个可以被读写的数据的位置。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。从读模式到写模式(clear),position 会归零,limit 设置为 capacity。
  • 标记 mark:Buffer 允许将位置直接定位到该标记处,这是一个可选属性

Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip 可以切换到读模式。如果要再次切换回写模式,可以调用 clear 或者 compact 方法。

position 、limit 和 capacity 之前的关系

Buffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer

1
2
3
4
// 分配堆内存
public static ByteBuffer allocate(int capacity);
// 分配直接内存
public static ByteBuffer allocateDirect(int capacity);

Channel

Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。Channel 和 Buffer 交互,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。

通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。

Channel 的子类如下所示:

Channel 的子类

其中,最常用的是以下几种类型的通道:

  • FileChannel:文件访问通道;
  • SocketChannelServerSocketChannel:TCP 通信通道;
  • DatagramChannel:UDP 通信通道;

Channel 最核心的两个方法:

  1. read :读取数据并写入到 Buffer 中。
  2. write :将 Buffer 中的数据写入到 Channel 中。
1
2
3
4
RandomAccessFile reader = new RandomAccessFile("/Users/guide/Documents/test_read.in", "r"))
FileChannel channel = reader.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);

Selector

Selector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel

Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。

Selector 选择器工作示意图

一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll() 代替传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。

Selector 可以监听以下四种事件类型:

  1. SelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel
  2. SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel
  3. SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读。
  4. SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。

Selector 是抽象类,可以通过调用此类的 open() 静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannelIO 状况,是非阻塞 IO 的核心。

一个 Selector 实例有三个 SelectionKey 集合:

  1. 所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回。
  2. 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel,这个集合可以通过 selectedKeys() 返回。
  3. 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。

Selector 还提供了一系列和 select() 相关的方法:

  • int select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。
  • int select(long timeout):可以设置超时时长的 select() 操作。
  • int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。
  • Selector wakeup():使一个还未返回的 select() 方法立刻返回.
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class NioSelectorExample {

public static void main(String[] args) {
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8080));

Selector selector = Selector.open();
// 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
int readyChannels = selector.select();

if (readyChannels == 0) {
continue;
}

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();

if (key.isAcceptable()) {
// 处理连接事件
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);

// 将客户端通道注册到 Selector 并监听 OP_READ 事件
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 处理读事件
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = client.read(buffer);

if (bytesRead > 0) {
buffer.flip();
System.out.println("收到数据:" +new String(buffer.array(), 0, bytesRead));
// 将客户端通道注册到 Selector 并监听 OP_WRITE 事件
client.register(selector, SelectionKey.OP_WRITE);
} else if (bytesRead < 0) {
// 客户端断开连接
client.close();
}
} else if (key.isWritable()) {
// 处理写事件
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.wrap("Hello, Client!".getBytes());
client.write(buffer);

// 将客户端通道注册到 Selector 并监听 OP_READ 事件
client.register(selector, SelectionKey.OP_READ);
}

keyIterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

NIO 直接读取 零拷贝

直接读取

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的⼀种实现,底层实际是调用了 Linux 内核的 mmap 系统调用。

它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。

1
2
3
4
5
6
7
8
9
10
private void loadFileIntoMemory(File xmlFile) throws IOException {
FileInputStream fis = new FileInputStream(xmlFile);
// 创建 FileChannel 对象
FileChannel fc = fis.getChannel();
// FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象
MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
xmlFileBuffer = new byte[(int)fc.size()];
mmb.get(xmlFileBuffer);
fis.close();
}

零拷贝

FileChanneltransferTo()/transferFrom()是 NIO 基于发送文件(sendfile)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的 sendfile系统调用。

它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区

AIO

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

img

IO 设计模式

在 Java IO 中,所用到的设计模式总结。

装饰器模式

装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。

对于字节流,FilterInputStream 和 FilterOutputStream 是装饰器模式的核心,用于增强 InputStream 和 OutputStream 子类对象的功能。

对于装饰器类而言,构造器的输入为被装饰类,同时可以嵌套多个装饰器。所以,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口

举例,BufferedInputStream 为 InputStream 提供了缓冲功能,ZipOutputStream 为 OutputStream 提供了 zip 压缩功能,他们的构造函数都是输出一个 InputStream 或者 OutputStream。

适配器模式

适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,适配器模式中存在被适配的对象或者类称为适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。

IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,可以将字节流对象适配成一个字符流对象,可以直接通过字节流对象来读取或者写入字符数据。

InputStreamReader OutputStreamWriter

之前提到的 InputStreamReaderOutputStreamWriter 就是两个适配器,是字节流和字符流之间的桥梁

  • InputStreamReader 使用 StreamDecoder(流解码器)对字节进行解码,实现字节流到字符流的转换
  • OutputStreamWriter 使用 StreamEncoder(流编码器)对字节进行编码,实现字符流到字节流的转换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class InputStreamReader extends Reader {
//用于解码的对象
private final StreamDecoder sd;
public InputStreamReader(InputStream in) {
super(in);
try {
// 获取 StreamDecoder 对象
sd = StreamDecoder.forInputStreamReader(in, this, (String)null);
} catch (UnsupportedEncodingException e) {
throw new Error(e);
}
}
// 使用 StreamDecoder 对象做具体的读取工作
public int read() throws IOException {
return sd.read();
}
}

工厂模式

工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files 类的 newInputStream 方法用于创建 InputStream 对象(静态工厂)、 Paths 类的 get 方法创建 Path 对象(静态工厂)、ZipFileSystem 类(sun.nio包下的类,属于 java.nio 相关的一些内部实现)的 getPath 的方法创建 Path 对象(简单工厂)。

1
InputStream is = Files.newInputStream(Paths.get(generatorLogoPath))

观察者模式

NIO 中的文件目录监听服务使用到了观察者模式。NIO 中的文件目录监听服务基于 WatchService 接口和 Watchable 接口。WatchService 属于观察者,Watchable 属于被观察者。使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建 WatchService 对象
WatchService watchService = FileSystems.getDefault().newWatchService();

// 初始化一个被监控文件夹的 Path 类:
Path path = Paths.get("workingDirectory");
// 将这个 path 对象注册到 WatchService(监控服务) 中去
WatchKey watchKey = path.register(watchService, StandardWatchEventKinds...);

while ((key = watchService.take()) != null) {
for (WatchEvent<?> event : key.pollEvents()) {
// 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息
}
key.reset();
}

Watchable

Path 实现了 Watchable 接口,说明 Path 是被观察者。Watchable 接口中,watcher 指定了观察者,events 指定了被观察的事件(监听事件)。常用的监听事件:StandardWatchEventKinds.ENTRY_CREATE:文件创建;StandardWatchEventKinds.ENTRY_DELETE : 文件删除;StandardWatchEventKinds.ENTRY_MODIFY : 文件修改。

1
2
3
4
5
6
7
8
9
10
public interface Path
extends Comparable<Path>, Iterable<Path>, Watchable{
}

public interface Watchable {
WatchKey register(WatchService watcher,
WatchEvent.Kind<?>[] events,
WatchEvent.Modifier... modifiers)
throws IOException;
}

WatchService

WatchService 内部通过一个 daemon thread(守护线程)采用定期轮询的方式来检测文件的变化,伪代码如下:

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
class PollingWatchService
extends AbstractWatchService
{
// 定义一个 daemon thread(守护线程)轮询检测文件变化
private final ScheduledExecutorService scheduledExecutor;

PollingWatchService() {
scheduledExecutor = Executors
.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setDaemon(true);
return t;
}});
}

void enable(Set<? extends WatchEvent.Kind<?>> events, long period) {
synchronized (this) {
// 更新监听事件
this.events = events;

// 开启定期轮询
Runnable thunk = new Runnable() { public void run() { poll(); }};
this.poller = scheduledExecutor
.scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS);
}
}
}

IO 流在 Java 中分为输入流和输出流,按照传输单位(数据处理方式)又分为字节流和字符流。Java 有四个 IO 流抽象类基类:

  • InputStream/OutputStream:字节输入流,字节输出流。
  • Reader/Writer:字符输入流,字符输出流。

字节流

InputStream

InputStream 用于从源头(如文件)读取字节数据到内存中,InputStream 的常用用法:

  • read():返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。
  • read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)
  • read(byte b[], int off, int len):在 read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • skip(long n):忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
  • available():返回输入流中可以读取的字节数。
  • close():关闭输入流释放相关的系统资源。

从 Java 9 开始,InputStream 新增加了多个实用的方法:

  • readAllBytes():读取输入流中的所有字节,返回字节数组。
  • readNBytes(byte[] b, int off, int len):阻塞直到读取 len 个字节。
  • transferTo(OutputStream out):将所有字节从一个输入流传递到一个输出流。

常见 InputStream

常用的 InputStream 如下:

  • FileInputStream:用于读取文件。
  • BufferInputStream:带缓冲区的 InputStream,缓冲区缓冲数据,有助于减少 IO。
  • DataInputStream:用于读取指定类型数据,如 readBoolean、readInt、readUTF 等。
  • ObjectInputStream:用于从输入流中读取 Java 对象(反序列化)。

OnjectInputStream 用于序列化和反序列化的类必须实现 Serializable 接口,对象中如果有属性不想被序列化,使用 transient 修饰。

阅读全文 »

Map

HashMap

HashMap 和 Hashtable 的区别

HashMap 和 Hashtable 的区别如下:

  • 线程安全:HashMap 不是线程安全的,Hashtable 是线程安全的(函数都由 synchronized 修饰,但是锁的粒度很大,并发度为一)。
  • 效率:HashMap 效率更高,因为 Hashtable 线程安全,效率更低(Hashtable 已被淘汰,不要使用)。
  • 对 Null 的支持:HashMap 支持 Null 的 Key 和 Value,而 Hashtable 不支持。
  • 初始容量和扩容大小:
    • Hashtable:如果不指定初始容量,默认初始容量为 11,每一次扩容 2n+1。
    • HashMap:如果不知道初始容量,默认初始容量为 16,每一次扩容 2n;指定初始容量时,容量扩充为 2 的幂次方大小。
  • 底层数据结构:HashMap 在 JDK 1.8 之前,采用数组+链表的方式,之后采用数组+链表/红黑树的方式(当链表长度大于16,会将链表转为红黑树。但是如果数组长度小于 64,会优先扩容数组)

为什么 HashMap 的容量是 2 的幂次方?

因为在通过对哈希取模选则数组下标时,2 的幂次方可以快速计算(n-1) & hash = hash % 数组长度

HashMap 和 TreeMap 的区别

HashMap 和 TreeMap 都继承自 AbstractMap,但是 TreeMap 还实现了 NavigableMap 接口和 SortedMap 接口。实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索能力;实现 SortedMap 接口让 TreeMap 有了对集合中元素根据 key 的排序能力。

TreeMap 继承关系图

HashMap 多线程死循环问题

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

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

ConcurrentHashMap

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式不同:

  • 底层数据结构:Hashtable 采用数组+链表的方式。ConcurrentHashMap 在 JDK 1.7 采用分段锁+链表,JDK 1.8 采用数组+链表/红黑树的方式。
  • 实现线程安全的方式:
    • ConcurrentHashMap:在 JDK 1.7 时,使用分段锁的方式保证线程安全;在 JDK 1.8 时,摒弃了分段锁而是直接使用 Node 数组+链表/红黑树的方式,并发控制使用 synchronized 和 CAS 操作
    • Hashtable:使用 synchronized(同一把锁)保证线程安全,并发程度为 1。

ConcurrentHashMap 并发实现机制

ConcurrentHashMap 在 JDK 1.7 和 1.8 中实现并发的机制不同:

  • JDK 1.7:分段锁的方式实现并发,默认 16 个段,也就是说最多支持 16 个线程并发访问。段的个数一旦初始化之后不能改变。
  • JDK 1.8:摈弃了分段锁的概念,采用 Node 数组+synchronized+CAS 保证并发安全。锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

ConcurrentHashMap 为什么 key 和 value 不支持 null

多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。

与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。

也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。

List

ArrayList、LinkedList、Vector 实现了 List 接口。其中 Vector 是 ArrayList 的古老实现类,最重要的还是 ArrayList 和 LinkedList。那么 ArrayList 和 LinkedList 的区别有什么?

  • 是否线程安全:二者都是线程不安全的,不保证并发访问。
  • 底层数据结构:ArrayList 底层采用数组,而 LinkedList 底层采用双向链表。
  • 插入和删除:ArrayList 除了在尾部插入和删除的时间复杂度为 O(1),其余时间复杂度均为 O(n)。LinkedList 在头尾插入和删除的时间复杂度为 O(1),其余位置因为需要先定位元素再删除,时间复杂度为 O(n)
  • 是否支持随机访问:ArrayList 支持快速的随机访问,而 LinkedList 不支持。
  • 内存占用:ArrayList 的空间浪费体现在容量大于长度的情况,LinkedList 的空间浪费体现在每一个元素都要记录前驱和后继。

一般都会使用 ArrayList,需要用到 LinkedList 的场景几乎可以用 ArrayList 代替。

阅读全文 »

集合概述

Java 集合(容器)由两大接口派生而来:Collection 接口,用于存放单一的元素;Map 接口,用于存放键值对。对于 Collection 接口,派生出了几个子接口:List、Set、Queue。

Java 集合框架概览

区别

几个接口的区别如下:

  • List:顺序存储元素。
  • Set:存储的元素是不可重复的。
  • Queue:队列,按照某种规则进行排队。
  • Map:键值对。

底层数据结构

Collection

Collection 接口下集合的数据结构:

  • List:
    • ArrayList:Object 数组。
    • Vector:Object 数组,并发安全,是 ArrayList 的古老实现类。
    • LinkedList:双向链表。
  • Set:
    • HashSet:基于 HashMap 实现,底层使用 HashMap 存储元素。
    • LinkedHashSet:是 HashSet 的子类,内部是通过 LinkedHashMap 实现的。
    • TreeSet:红黑树。
  • Queue:
    • PriorityQueue:Object 数组实现的小顶堆。
    • DelayQueue:延迟队列,基于 PriorityQueue 实现的。
    • ArrayDeque:可以动态扩容的双向数组。

Map

Map 接口下集合的数据结构:

  • HashMap:JDK 1.8 之前由数组+链表组成,使用拉链法解决哈希冲突。JDK 1.8 之后在解决哈希冲突时有了较大变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
  • LinkedHashMap:继承自 HashMap,底层依然是数组+链表/红黑树的结构。另外,LinkedHashMap 在HashMap 的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了顺序访问相关逻辑。
  • Hashtable:数组+链表组成,使用拉链法解决哈希冲突。
  • TreeMap:红黑树。

GaiaDB 是百度自研的云原生数据库,GaiaDB 采用计算与存储分离的架构,不仅在性能、扩展性和高可用方面有大幅提升,而且架构的解耦使得计算层和存储层都获得了很大的优化空间。

架构

GaiaDB 在运行形态上是一个多节点集群,集群中有一个主节点和多个从节点日志服务(LogService)用于持久化主节点生成的日志,所有节点共享底层的分布式存储(PageServer),各模块分布到不同的K8S容器内。

架构上采用合-分-合的架构,在扩展性和使用便捷性之间保持了平衡,使得对于上层应用程序来说,就像使用一个单点的MySQL数据库一样简单。

  • 【合】最上层是接入代理,作为统一的入口。
  • 【分】中间数据库引擎作为计算层,扩展为一个主节点合多个从节点,提高扩展性。
  • 【合】底层采用一套分布式存储,节省成本。

img

GaiaDB 代理层

GaiaDB 通过内部的代理层(Proxy)对外提供服务,也就是说所有的应用程序都先经过这层代理,然后才访问到具体的数据库节点。这样的好处:

  • Proxy 提供安全认证和保护。
  • Proxy 解析 SQL,把写操作(比如事务、Update、Insert、Delete、DDL 等)发送到主节点,把读操作(比如 Select)均衡地分发到多个读节点,这个也叫读写分离
  • 通过 Proxy 把不同的业务用不同的连接地址,使用不同的数据节点。这样就可以避免相互影响,不会影响在线业务。

img

GaiaDB 计算层

计算层由一个主节点合最多十五个从节点组成。计算层百分百支持 MySQL 语法,相对于 MySQL 的主从节点来说,GaiaDB 的计算节点是无状态的。

  • 主节点:处理读写请求,处理事务。相比于 MySQL 的主节点,GaiaDB 的主节点仅需要将日志(RedoLog)发送至日志服务(LogService)持久化,不需要写数据。数据是由数据存储节点(PageServer)从 LogService 拉取日志并回放。
  • 从节点:处理只读负载。从 PageServer 拉取最新的数据。

这样的设计具有以下优势

  • 计算节点无状态,弹性快速扩容。计算层节点无持久化数据,本地文件不复存在,包括日志文件,所以支持快速扩容,大概在 30 秒内就能快速创建从节点并提供服务。
  • 只有单机事务,没有分布式事务问题。集群所有的事务都请求到主节点,主节点自身保障事务的 ACID 特性
  • 单机故障不影响集群一致性,底层存储层利用 Raft 一致性算法保障数据的一致性。事务产生的 Redo 日志均实时写入 LogService,从节点利用 PageServer 上的数据文件和 LogService 中的 Redo 日志,在内存中恢复最新的数据对外提供服务。每一次主备故障切换,从节点均可以获取最新的事务 Redo 日志,因此不会出现数据丢失。

GaiaDB 存储层

存储层是由多组数据存储节点(PageServer)和一组日志服务节点(LogService)组成。包括两个组件,LogService 和 Page Server。

  • LogService 用于持久化数据库日志(RedoLog),采用 Raft 一致性协议保障日志的高可用和一致性。缓存近期产生的日志,供从节点和 PageServer 快速拉取。

  • PageServer 用于缓存和持久化数据页,每组包含三个 PageServer 节点,一组 PageServer 管理 32GB 的数据(这 32GB 的数据称为一个 Segment)。

img

这样的设计有以下优势

  • 数据的一致性和数据存储分离,LogService 专注一致性保障,PageServer 专注持久化数据。
  • PageServer 和 LogService 都采用多副本的方式保障高可用。
  • PageServer 之间逻辑独立。独立从 LogService 拉取数据,LogService 保障数据的一致性。
  • 存储节点的扩容只需要增加 PageServer 的个数,扩容操作对用户无感