【简历优化】性能调优 — 编程性能调优篇

news/2025/2/22 16:18:38

在这里插入图片描述

😊你好,我是小航,一个正在变秃、变强的文艺倾年。
🔔本文讲解【简历优化】性能调优 — 编程性能调优篇,期待与你一同探索、学习、进步,一起卷起来叭!

目录

  • 一、编程性能调优
    • 字符串
      • String 发展
      • 优化点
    • 正则表达式
      • 组成
      • 回溯问题
      • 优化点
    • ArrayList && LinkedList
      • List接口
      • ArrayList
      • LinkedList
    • Stream
      • 实现原理
      • 使用测试
    • HashMap
      • 哈希冲突
      • 源码分析
    • I / O 模型
      • I/O 操作类
      • 网络 I/O 模型
      • 传统 I/O(BIO)
      • NIO1
      • NIO2
      • Reactor模型
    • Java序列化
      • 实现原理
      • 缺陷
      • 解决方案
    • RPC
      • RMI
      • RPC优化路径

一、编程性能调优

字符串

String 发展

在 Java 语言中,Sun 公司的工程师们对 String 对象做了大量的优化,来节约内存空间,提升 String 对象在系统中的性能。优化过程如下:

在这里插入图片描述
(1)Java6 以及之前的版本:

  • 成员变量:char 数组、偏移量 offset、字符数量 count、哈希值 hash。
  • 通过 offset 和 count 两个属性来定位 char[] 数组,获取字符串。
  • 优点:可以高效、快速地共享数组对象,同时节省内存空间。
  • 缺点:有可能会导致内存泄漏。

(2) Java7 - Java8 :

  • 移除变量:offset 、count
  • 优点:
    • String 对象占用的内存变少
    • String.substring 方法也不再共享 char[],解决了使用该方法可能导致的内存泄漏问题。

(3)Java9 +:

  • 变量: char[] 字段改为了 byte[] 字段,新增属性 coder,用于标识编码格式,0 代表 Latin-1(单字节编码),1 代表 UTF-16,计算字符串长度、使用 indexOf()函数会用到。
  • 优点: byte占一个字节、char字符占两个字节,节约内存空间。

优化点

(1)字符串拼接

  • 使用 + 号作为字符串的拼接,可以被编译器优化成 StringBuilder 的方式。但可能会产生多余的StringBuilder 实例。
  • 推荐使用显示使用 String Builder 来提升系统性能。
  • 多线程编程中,涉及到锁竞争,可以使用 StringBuffer。

(2)使用String.intern

知识点:如果调用 intern 方法,会去查看字符串常量池中是否有等于该对象的字符串

  • 如果没有,就在常量池中新增该对象,并返回该对象引用。
  • 如果有,就返回常量池中的字符串引用。堆内存中原有的对象由于没有引用指向它,将会通过垃圾回收器回收。

常量池的实现是类似于一个 HashTable 的实现方式,HashTable 存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。

案例:Twitter 每次发布消息状态的时候,都会产生一个地址信息,以当时 Twitter 用户的规模预估,服务器需要 32G 的内存来存储地址信息。

java">public class Location {
    private String city;
    private String region;
    private String countryCode;
    private double longitude;
    private double latitude;
} 

有很多用户在地址信息上是有重合的,比如,国家、省份、城市等。提取这部分信息为单独一个类。【32GB -> 20GB】

java">public class SharedLocation { 
	private String city;
	private String region;
	private String countryCode;
}
 
public class Location {
	private SharedLocation sharedLocation;
	double longitude;
	double latitude;
}

每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。【20GB -> xxxMB】

java">SharedLocation sharedLocation = new SharedLocation();
 
sharedLocation.setCity(messageInfo.getCity().intern());		
sharedLocation.setCountryCode(messageInfo.getRegion().intern());
sharedLocation.setRegion(messageInfo.getCountryCode().intern());
 
Location location = new Location();
location.set(sharedLocation);
location.set(messageInfo.getLongitude());
location.set(messageInfo.getLatitude());

(3)Split() 方法

  • Split() 方法使用了正则表达式,使用不恰当会引起回溯问题。
  • 可以使用String.indexOf() 方法代替 Split() 方法完成字符串的分割。
  • Java8开始,String.split() 传入长度为1字符串的时候并不会使用正则。
    • 传入的参数长度为1,且不包含“.$|()[{^?*+\”regex元字符的情况下,不会使用正则表达式;
    • 传入的参数长度为2,第一个字符是反斜杠,并且第二个字符不是ASCII数字或ASCII字母的情况下,不会使用正则表达式。

正则表达式

组成

正则表达式使用一些特定的元字符来检索、匹配以及替换符合规则的字符串。元字符由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成。

在这里插入图片描述

回溯问题

  • 原因:正则表达式库都是基于 NFA(Non deterministic Finite Automaton 非确定有限状态自动机) 实现的。
  • 匹配模式:
    • 贪婪模式:在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。
    • 懒惰模式:正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。
    • 独占模式:同贪婪模式一样,独占模式一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。

优化点

(1)少用贪婪模式,多用独占模式,避免回溯

(2)减少分支选择:(X|Y|Z)

  • 考虑选择的顺序,将常用的选择项放到前面。
  • 提取公用模式,例如将“(abcd|abef)”替换为“ab(cd|ef)”。
  • 对于简单的分支选择类型,例如使用三次indexof方法代替“(X|Y|Z)”

(3)减少捕获异常:

  • 捕获组:正则表达式中用 () 包起来的部分,这些 () 会把匹配到的内容“抓”出来,并且保存下来,方便后面使用。捕获组可以进行嵌套。
    • (<input.*?>)(.*?)(</input>)
    • 第一个 () 抓的是 <input...> 部分
    • 第二个 () 抓的是标签中间的内容(比如 test)
    • 第三个 () 抓的是 </input> 部分
  • 非捕获组:参与匹配却不进行分组编号的捕获组,其表达式一般由(?:exp)组成。

捕获组 vs 非捕获组:
捕获组:

java">String text = "<input high=\"20\" weight=\"70\">test</input>";
String reg = "(<input.*?>)(.*?)(</input>)";
Pattern p = Pattern.compile(reg);
Matcher m = p.matcher(text);

while (m.find()) {
    System.out.println(m.group(0)); // 整个匹配到的内容
    System.out.println(m.group(1)); // <input...>
    System.out.println(m.group(2)); // test
    System.out.println(m.group(3)); // </input>
}

<input high="20" weight="70">test</input>
<input high="20" weight="70">
test
</input>

非捕获组:

java">String text = "<input high=\"20\" weight=\"70\">test</input>";
String reg = "(?:<input.*?>)(.*?)(?:</input>)";
Pattern p = Pattern.compile(reg);
Matcher m = p.matcher(text);

while (m.find()) {
    System.out.println(m.group(0)); // 整个匹配到的内容
    System.out.println(m.group(1)); // test
}

<input high="20" weight="70">test</input>
test

ArrayList && LinkedList

List接口

在这里插入图片描述

ArrayList

实现类:

(1)ArrayList 实现了 List 接口,继承了 AbstractList 抽象类底层是数组实现的,并且实现了自增扩容数组大小。
(2)ArrayList 实现了 Cloneable 接口和 Serializable 接口,所以他可以实现克隆和序列化。
(3)ArrayList 实现了 RandomAccess 接口,能实现快速随机访问。

RandomAccess是一个空接口,只要实现该接口的 List 类,都能实现快速随机访问。

java">public class ArrayList<E> extends AbstractList<E> 
		implements List<E>, RandomAccess, Cloneable, java.io.Serializable

属性:由数组长度 size、对象数组 elementData、初始化容量 default_capacity 等组成, 其中初始化容量默认大小为 10。

java">// 默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
// 对象数组
transient Object[] elementData; 
// 数组长度
private int size;

transient 关键字修饰 elementData 数组 表示该属性不会被序列化,但 ArrayList 其实是实现了序列化接口。

(1)ArrayList 的数组是基于动态扩增的,所以并不是所有被分配的内存空间都存储了数据。
(2)如果采用外部序列化法实现数组的序列化,会序列化整个数组。会导致没有存储数据的内存空间被序列化。
(3)使用 transient 修饰数组,防止对象数组被其他外部方法序列化。
(4)内部提供了两个私有方法 writeObject 以及 readObject ,来自我完成序列化与反序列化


构造函数:🚩在 ArrayList 初始化时指定数组容量大小,减少数组的扩容次数,提高系统性能。

  • 创建 ArrayList 对象时,传入一个初始化值
  • 默认创建一个空数组对象
  • 传入一个集合类型进行初始化。

新增元素:🚩在添加元素时,只在数组末尾添加元素,那么 ArrayList 在大量新增元素的场景下,性能并不会变差,反而比其他 List 集合的性能要好。【在没有发生扩容的前提下

(1)在添加元素之前,先确认容量大小,如果容量够大,就不用进行扩容;如果容量不够大,就会按照原来数组的 1.5 倍大小进行扩容,在扩容之后需要将数组复制到新分配的内存地址。🚩尽量在ArrayList 初始化时指定数组容量大小

java">private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

(2)添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列。
(3)将元素添加到数组的末尾,在没有发生扩容的前提下,是不会有元素复制排序过程的。


删除元素:🚩删除的元素位置越靠前,数组重组的开销就越大。

java">public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

遍历元素:随机访问

java">public E get(int index) {
    rangeCheck(index);

    return elementData(index);
}

E elementData(int index) {
    return (E) elementData[index];
}

LinkedList

JDK1.7之前:LinkedList 中只包含了一个 Entry 结构的 header 属性,并在初始化的时候默认创建一个空的 Entry,用来做 header,前后指针指向自己,形成一个循环双向链表。

Entry 结构:

java">private static class Entry<E> {
    E element;          // 存储的数据
    Entry<E> next;      // 指向下一个节点
    Entry<E> prev;      // 指向前一个节点

    Entry(E element, Entry<E> next, Entry<E> prev) {
        this.element = element;
        this.next = next;
        this.prev = prev;
    }
}

header:

java">transient Entry<E> header = new Entry<>(null, null, null);
header.next = header;
header.prev = header;

JDK1.7之后:链表的 Entry 结构换成了 Node,内部组成基本没有改变,但 LinkedList 里面的 header 属性去掉了,新增了一个 Node 结构的 first 属性和一个 Node 结构的 last 属性。

Node结构:

java">private static class Node<E> {
    E item;             // 存储的数据
    Node<E> next;       // 指向下一个节点
    Node<E> prev;       // 指向前一个节点

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.prev = prev;
        this.next = next;
    }
}

first、last属性:

java">transient Node<E> first = null;
transient Node<E> last = null;

实现类:
(1)实现了 List 接口、Deque 接口
(2)继承了 AbstractSequentialList 抽象类
(3)实现了 Cloneable 和 Serializable 接口,实现克隆和序列化。

java">public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

属性:first、last 、size 属性
🚩在序列化的时候不会只对头尾进行序列化,自行实现 readObject 和 writeObject 进行序列化与反序列化。

java">transient int size = 0;
transient Node<E> first;
transient Node<E> last;

新增元素:
(1)添加到队尾:将 last 元素置换到临时变量中,生成一个新的 Node 节点对象,然后将 last 引用指向新节点对象,之前的 last 对象的前指针指向新节点对象。

java">public boolean add(E e) {
    linkLast(e);
    return true;
}

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

(2)添加到任意位置:将元素添加到任意两个元素的中间位置,添加元素操作只会改变前后元素的前后指针,指针将会指向添加的新元素。

java"> public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}
 
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

删除元素:

🚩要删除的位置处于 List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。

🚩List 拥有大量元素,移除的元素又在 List 的中间段,那效率相对来说会很低。


遍历元素:

🚩在 LinkedList 循环遍历时,可以使用 iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List。

Stream

Java 8中,Collection 新增了两个流方法,分别是 Stream() 和 parallelStream()。

实现原理

操作类型:

  • 中间操作(Intermediate operations,还被称为懒操作):只对操作进行了记录,即只会返回一个流,不会进行计算操作。
    • 无状态(Stateless):元素的处理不受之前元素的影响
    • 有状态(Stateful):该操作只有拿到所有元素之后才能继续下去。
  • 终结操作(Terminal operations):实现了计算操作。
    • 短路(Short-circuiting):遇到某些符合条件的元素就可以得到最终结果。
    • 非短路(Unshort-circuiting):必须处理完所有元素才能得到最终结果。

正是由这种中间操作结合终结操作、数据源构成的处理管道(Pipeline),实现了 Stream 的高效。

在这里插入图片描述


源码实现:

在这里插入图片描述
(1)BaseStream :定义了流的基本接口方法,例如,spliterator、isParallel 等;

(2)Stream :定义了一些流的常用操作方法,例如,map、filter 等。

(3)ReferencePipeline 是一个结构类,他通过定义内部类组装了各种操作流。他定义了 Head、StatelessOp、StatefulOp 三个内部类,实现了 BaseStream 与 Stream 的接口方法。ReferencePipeline 最终会将整个 Stream 流操作组装成一个调用链。

  • 初次调用.stream()方法时,会初次加载Head对象,用于定义数据源操作。
  • 接着加载中间操作,有无状态中间操作、有状态中间操作,此时Stage 并没有执行,而是通过 AbstractPipeline 生成了一个中间操作 Stage 链表。
  • 调用终结操作时,会生成一个最终Stage,通过这个 Stage 触发之前的中间操作,从最后一个 Stage 开始,递归产生一个 Sink 链。

(4)Sink 接口:定义每个 Stream 操作之间关系的协议,他包含 begin()、end()、cancellationRequested()、accpt() 四个方法。ReferencePipeline组成的调用链上的各个 Stream 操作的上下关系就是通过 Sink 接口协议来定义实现的。


Stream 操作叠加:

调用流程分析

java">List<String> names = Arrays.asList(" 张三 ", " 李四 ", " 王老五 ", " 李三 ", " 刘老四 ", " 王小二 ", " 张四 ", " 张五六七 ");

String maxLenStartWithZ = names.stream()
   	            .filter(name -> name.startsWith(" 张 "))
   	            .mapToInt(String::length)
   	            .max()
   	            .toString();

(1)names.stream() 调用集合类基础接口 Collection 的 Stream 方法

java">default Stream<E> stream() {
  return StreamSupport.stream(spliterator(), false);
}

(2)Stream 方法调用 StreamSupport 类的 Stream 方法,方法中初始化了一个 ReferencePipeline 的 Head 内部类对象:

java">public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
        Objects.requireNonNull(spliterator);
        return new ReferencePipeline.Head<>(spliterator,
          StreamOpFlag.fromCharacteristics(spliterator),
                                            parallel);
}

(3)调用 filter 和 map 方法,这两个方法都是无状态的中间操作,不进行任何操作,分别创建一个 Stage 来标识用户的每一次操作。Stage包含:数据来源、操作、回调函数。创建每一个 Stage 时,都会包含一个 opWrapSink() 方法,该方法会把一个操作的具体实现封装在 Sink 类中,Sink 采用(处理 -> 转发)的模式来叠加操作

java">@Override
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
    Objects.requireNonNull(predicate);
    return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SIZED) {
        @Override
        Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
            return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
                @Override
                public void begin(long size) {
                    downstream.begin(-1);
                }

                @Override
                public void accept(P_OUT u) {
                    if (predicate.test(u))
                        downstream.accept(u);
                }
            };
        }
    };
}

@Override
@SuppressWarnings("unchecked")
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
    Objects.requireNonNull(mapper);
    return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
        @Override
        Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
            return new Sink.ChainedReference<P_OUT, R>(sink) {
                @Override
                public void accept(P_OUT u) {
                    downstream.accept(mapper.apply(u));
                }
            };
        }
    };
}

(4)new StatelessOp 将会调用父类 AbstractPipeline 的构造函数,这个构造函数将前后的 Stage 联系起来,生成一个 Stage 链表:

java">AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
    if (previousStage.linkedOrConsumed)
        throw new IllegalStateException(MSG_STREAM_LINKED);
    previousStage.linkedOrConsumed = true;
    previousStage.nextStage = this;// 将当前的 stage 的 next 指针指向之前的 stage

    this.previousStage = previousStage;// 赋值当前 stage 当全局变量 previousStage 
    this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
    this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
    this.sourceStage = previousStage.sourceStage;
    if (opIsStateful())
        sourceStage.sourceAnyStateful = true;
    this.depth = previousStage.depth + 1;
}

(5)执行 max 方法时,调用 ReferencePipeline 的 max 方法,此时由于 max 方法是终结操作,所以会创建一个 TerminalOp 操作,同时创建一个 ReducingSink,并且将操作封装在 Sink 类中。

java">@Override
public final Optional<P_OUT> max(Comparator<? super P_OUT> comparator) {
    return reduce(BinaryOperator.maxBy(comparator));
}

(6)调用 AbstractPipeline 的 wrapSink 方法,该方法会调用 opWrapSink 生成一个 Sink 链表,Sink 链表中的每一个 Sink 都封装了一个操作的具体实现。

java">@Override
@SuppressWarnings("unchecked")
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
    Objects.requireNonNull(sink);

    for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
        sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
    }
    return (Sink<P_IN>) sink;
}

(7)Sink 链表生成完成后,Stream 开始执行,通过 spliterator 迭代集合,执行 Sink 链表中的具体操作。

java">@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
    Objects.requireNonNull(wrappedSink);

    if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
        wrappedSink.begin(spliterator.getExactSizeIfKnown());
        spliterator.forEachRemaining(wrappedSink);
        wrappedSink.end();
    }
    else {
        copyIntoWithCancel(wrappedSink, spliterator);
    }
}

Stream 并行处理:

实现方式:多调用.parallel()方法即可

java">List<String> names = Arrays.asList(" 张三 ", " 李四 ", " 王老五 ", " 李三 ", " 刘老四 ", " 王小二 ", " 张四 ", " 张五六七 ");
 
String maxLenStartWithZ = names.stream()
                    .parallel()
    	            .filter(name -> name.startsWith(" 张 "))
    	            .mapToInt(String::length)
    	            .max()
    	            .toString();

Stream 的并行处理在执行终结操作之前,跟串行处理的实现是一样的。而在调用终结方法之后,实现的方式就有点不太一样,会调用 TerminalOp 的 evaluateParallel 方法进行并行处理

java"> final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
    assert getOutputShape() == terminalOp.inputShape();
    if (linkedOrConsumed)
        throw new IllegalStateException(MSG_STREAM_LINKED);
    linkedOrConsumed = true;

    return isParallel()
           ? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
           : terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}

Stream 结合了 ForkJoin 框架,对 Stream 处理进行了分片,Splititerator 中的 estimateSize 方法会估算出分片的数据量。

使用测试

(1)在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;
(2)在单核 CPU 服务器配置环境中,常规迭代方式更有优势;
(3)在大数据循环迭代中,如果服务器是多核 CPU 的情况下,Stream 的并行迭代优势明显。

使用一个容器装载 100 个数字,通过 Stream 并行处理的方式将容器中为单数的数字转移到容器 parallelList

java">List<Integer> integerList= new ArrayList<Integer>();
 
for (int i = 0; i <100; i++) {
      integerList.add(i);
}
 
List<Integer> parallelList = new ArrayList<Integer>() ;
integerList.stream()
           .parallel()
           .filter(i->i%2==1)
           .forEach(i->parallelList.add(i));

(1)ArrayList 线程不安全,应该使用线程安全的集合,如:CopyOnWriteArrayList。【copyOnwriteList 适合某一时间段统一新增,且新增时避免大量操作容器发生,比较适合在深夜更新黑名单类似的业务。】
(2)使用线程安全的收集器(Collectors.toCollection)来生成结果列表。

HashMap

在这里插入图片描述

java">public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

(1)JDK1.8之前的HashMap由数组+链表组成.
(2)JDK1.8之后,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此索引位置上的所有数据改为使用红黑树存储。【如果阈值大于8,但是数组长度小64,此时并不会将链表变为红黑树。而是选择进行数组扩容,因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡。】

在这里插入图片描述

哈希冲突

当两个对象的存储地址发生冲突,称为哈希冲突。解决的方案有:开放定址法、再哈希函数法和链地址法。

  • 开放定址法:当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把 key 存放到冲突位置的空位置上去。这种方法存在着很多缺点,例如,查找、扩容等。探查空位置的方法有:
    • 线性探查:顺序查看表中下一单元,直到找出一个空单元或查遍全表。
    • 二次探查:在表的左右进行跳跃式探测。
    • 伪随机探测:生成一个位随机序列,并给定一个随机数做起点,每次去加上这个伪随机数++就可以了。
  • 再哈希法:输出是同一个位置就再次哈希,直至不发生冲突位置。这种方法不易产生“聚集”,但却增加了计算时间。
  • 链地址法:采用了数组(哈希表)+ 链表的数据结构,当发生哈希冲突时,就用一个链表结构存储相同 Hash 值的数据。

在这里插入图片描述
在这里插入图片描述

源码分析

HashMap组成:

java">transient Node<K,V>[] table; // 哈希桶数组,默认数组大小:16
int threshold; // 边界值(阈值)= table.length * loadFactor,默认值:12
// 当map里面的数据大于这个threshold就会进行扩容,调用 resize() 方法重新分配 table 数组,会涉及数组在内存区域中复制,影响效率。
final float loadFactor; // 加载因子,默认值:0.75
// 加载因子用来衡量HashMap满的程度,表示当map集合中存储的数据达到当前数组大小的75%则需要进行扩容

我们可以在预知存储数据量的情况下,提前设置初始容量(初始容量 = 预知数据量 / 加载因子)。这样做的好处是可以减少 resize() 操作,提高 HashMap 的效率。

实际应用中,我们设置初始容量,一般得是 2 的整数次幂。
(1)使用2的幂大小时,模运算被位与操作取代,位与操作的计算成本更低。例如,如果数组大小是16(2的4次方),表达式 hash & 15 可以高效地计算索引,等同于 hash % 16。
(2)以目前的算法技术,当HashMap的容量为2的幂次方时,可以简单巧妙的实现快速截取hash值作为散列值的效果,从而避免了使用%运算做散列 这样大量的时间消耗。
(3)而散列的均匀不均匀,是对象hashcode本身的功能(功劳),这里只是对这个hashcode截取了一段有效(能区分对象)且能控制范围的值。

Node内部类:

java">static class Node<K,V> implements Map.Entry<K,V> {
     final int hash; // 用来定位数组索引位置
     final K key;
     V value;
     Node<K,V> next;

     Node(int hash, K key, V value, Node<K,V> next) {
         this.hash = hash;
         this.key = key;
         this.value = value;
         this.next = next;
     }
}

添加元素:

当程序将一个 key-value 对添加到 HashMap 中,程序调用put方法:

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

首先会根据该 key 的 hashCode() 返回值,再通过 hash() 方法计算出 hash 值

java">static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

再通过 putVal 方法中的 (n - 1) & hash 决定该 Node 的存储位置。

java">if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 通过 putVal 方法中的 (n - 1) & hash 决定该 Node 的存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

关于hash() 的理解:(h = key.hashCode()) ^ (h >>> 16);
h >>> 16 代表无符号右移 16 位,也就是取 int 类型的一半,刚好可以将该二进制数对半切开,并且使用位异或运算(如果两个数对应的位置相反,则结果为 1,反之为 0)。

关于 (n-1)&hash 的理解:防止索引越界
这里的 n 代表哈希表的长度,哈希表习惯将长度设置为 2 的 n 次方,这样恰好可以保证 (n - 1) & hash 的计算得到的索引值总是位于 table 数组的索引之内。例如:hash=15,n=16 时,结果为 15;hash=17,n=16 时,结果为 1。

put整个流程:

在这里插入图片描述

java">final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当 table 为 null 或者 tab 的长度为 0 时,即 table 尚未初始化,此时通过 resize() 方法得到初始化的 table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为 tab 的下标 i,并另 p 表示 tab[i],也就是该链表第一个节点的位置。并判断 p 是否为 null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当 p 为 null 时,表明 tab[i] 上没有任何元素,那么接下来就 new 第一个 Node 节点,调用 newNode 方法返回新节点赋值给 tab[i]
        else {
//2.1 下面进入 p 不为 null 的情况,有三种情况:p 为链表节点;p 为红黑树节点;p 是链表节点但长度为临界长度 TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap 中判断 key 相同的条件是 key 的 hash 相同,并且符合 equals 方法。这里判断了 p.key 是否和插入的 key 相等,如果相等,则将 p 的引用赋给 e
 
                e = p;
            else if (p instanceof TreeNode)
//2.1.2 现在开始了第一种情况,p 是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型 p 后调用 TreeNode.putTreeVal 方法,返回的引用赋给 e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3 接下里就是 p 为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表 / 插入后转红黑树。另外,上行转型代码也说明了 TreeNode 是 Node 的一个子类
                for (int binCount = 0; ; ++binCount) {
// 我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount 就是这个计数器
 
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加 1,而 binCount 并不包含新节点,所以判断时要将临界阈值减 1
                            treeifyBin(tab, hash);
// 当新长度满足转换条件时,调用 treeifyBin 方法,将该链表转换为红黑树
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

I / O 模型

I/O 操作类

Java 的 I/O 操作类在包 java.io 下,其中 InputStream、OutputStream 以及 Reader、Writer 类是 I/O 包中的 4 个基本类,它们分别处理字节流和字符流。如下图所示:

在这里插入图片描述

不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
(1)字符到字节必须经过转码,这个过程非常耗时,字符流允许开发者直接操作字符或字符串,而不是手动解析字节流中的编码信息。
(2)字符(如字母、数字、汉字)在计算机中是以编码形式存储的(如 ASCII、UTF-8、GBK 等)。不同的编码方式会将同一个字符映射到不同的字节序列。如果直接使用字节流处理文本数据,可能会导致编码问题。
(3)字节流适合处理原始的二进制数据。字符流则提供了对文本数据的更高层次抽象,简化了编码和字符处理。


(1) 字节流

在这里插入图片描述
(1)文件的读写操作:FileInputStream、FileOutputStream;
(2)数组的读写操作:ByteArrayInputStream、ByteArrayOutputStream;
(3)普通字符串的读写操作:BufferedInputStream、BufferedOutputStream。

(2)字符流

在这里插入图片描述

网络 I/O 模型

随着技术的发展,操作系统内核的网络模型衍生出了五种 I/O 模型,《UNIX 网络编程》一书将这五种 I/O 模型分为阻塞式 I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O 和异步 I/O

  • 阻塞式 I/O:当发出一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待相应的操作响应。 可能存在的阻塞包括三种:
    • connect 阻塞:在这里插入图片描述
    • accept 阻塞:在这里插入图片描述
    • read、write 阻塞:在这里插入图片描述
  • 非阻塞式 I/O:使用 fcntl 可以把以上三种操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会一直被阻塞。但需要设置一个线程对该操作进行轮询检查,如果使用用户线程轮询查看一个 I/O 操作的状态,在大量请求的情况下,这对于 CPU 的使用率无疑是种灾难。
    在这里插入图片描述
  • I/O 复用:Linux 提供了 I/O 复用函数 select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮我们侦测多个读操作是否处于就绪状态。
    • select()函数:在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。调用后 select() 函数会阻塞,直到有描述符就绪或者超时,函数返回。当 select 函数返回后,可以通过函数 FD_ISSET 遍历 fdset,来找到就绪的描述符。在这里插入图片描述
      • fd_set 可以理解为一个集合,这个集合中存放的是文件描述符,可通过四个宏进行设置。
      • select() 函数监视的文件描述符分 3 类,分别是 writefds(写文件描述符)、readfds(读文件描述符)以及 exceptfds(异常事件文件描述符)。
    • poll() 函数:在这里插入图片描述
      • poll() 的机制与 select() 类似,二者在本质上差别不大。
      • poll() 管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制
      • poll() 和 select() 存在一个相同的缺点,那就是包含大量文件描述符的数组被整体复制到用户态和内核的地址空间之间,而无论这些文件描述符是否就绪,他们的开销都会随着文件描述符数量的增加而线性增大。
    • epoll() 函数:
      • select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量不宜过大,因此它的使用受到了一些制约。
      • Linux 在 2.6 内核版本中提供了一个 epoll 调用,epoll 使用事件驱动的方式代替轮询扫描 fd
      • epoll 事先通过 epoll_ctl() 来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的,所以在大量 I/O 请求的场景下,插入和删除的性能比 select/poll 的数组 fd_set 要好。在这里插入图片描述
  • 信号驱动式 I/O:实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。
    • 用户进程发起一个 I/O 请求操作,会通过系统调用 sigaction 函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关 I/O 操作。在这里插入图片描述
    • 信号驱动式 I/O 几乎没有被TCP使用:因为 SIGIO 信号是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket 生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。
    • 信号驱动式 I/O 被用在了 UDP 通信上:UDP 只有一个数据请求事件,这也就意味着在正常情况下 UDP 进程只要捕获 SIGIO 信号,就调用 recvfrom 读取到达的数据报。如果出现异常,就返回一个异常错误。比如,NTP 服务器就应用了这种模型。
  • I/O 复用:实现了真正的非阻塞 I/O。当用户进程发起一个 I/O 请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知进程。这个操作包括等待数据就绪和数据从内核复制到用户空间。由于程序的代码复杂度高,调试难度大,且支持异步 I/O 的操作系统比较少见(目前 Linux 暂不支持,而 Windows 已经实现了异步 I/O),所以在实际生产环境中很少用到异步 I/O 模型。在这里插入图片描述

传统 I/O(BIO)

问题:

  • 多次内存复制:通过 InputStream 从源数据中读取数据流输入到缓冲区里,通过 OutputStream 将数据输出到外部设备(包括磁盘、网络)。在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低 I/O 的性能。
    在这里插入图片描述
  • 阻塞:InputStream 的 read() 是一个 while 循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。

BIO(Blocking IO) 又称同步阻塞IO,一个客户端由一个线程来进行处理,伪代码如下:
在这里插入图片描述
可以看到服务端的线程阻塞在两个地方

  • 接受客户端请求的accept()函数
  • 读取数据的read()函数

Tomcat 中经常被提到的一个调优就是修改线程的 I/O 模型。Tomcat 8.5 版本之前,默认情况下使用的是 BIO 线程模型,如果在高负载、高并发的场景下,可以通过设置 NIO 线程模型,来提高系统的网络通信性能。

NIO1

在这里插入图片描述

JDK1.4 发布了 java.nio 包(new I/O 的缩写),NIO 的发布优化了内存复制以及阻塞导致的严重性能问题。

(1)使用缓冲区优化读写流操作:

  • 传统 I/O 是面向流,NIO 是面向 Buffer。
  • Buffer 是一块连续的内存块,是 NIO 读写数据的中转地,Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。
  • 虽然传统 I/O 后面也使用了缓冲块,例如BufferedInputStream,但仍然不能和 NIO 相媲美。

(2)使用 DirectBuffer 减少内存复制:【零拷贝:一种避免多次内存复制的技术,用来优化读写 I/O 操作。】

  • NIO提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存
  • 数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而DirectBuffer 直接将步骤简化为从内核空间复制到外部设备,减少了数据拷贝
  • DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。
  • DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java Reference 机制来释放该内存块

Linux 内核中的 mmap 函数可以代替 read、write 的 I/O 读写操作,实现用户空间和内核空间共享一个缓存数据。mmap 将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理内存地址。这种方式避免了内核空间与用户空间的数据交换。I/O 复用中的 epoll 函数中就是使用了 mmap 减少了内存拷贝。

(3)避免阻塞

  • 传统的 I/O 即使使用了缓冲块,依然存在阻塞问题。由于线程池线程数量有限,一旦发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。
  • NIO提供通道和多路复用器组件:
    • 通道(Channel):
      • 最开始,在应用程序调用操作系统 I/O 接口时,是由 CPU 完成分配,这种方式最大的问题是“发生大量 I/O 请求时,非常消耗 CPU”
      • 之后,操作系统引入了 DMA(直接存储器存储),内核空间与磁盘之间的存取完全由 DMA 负责,但这种方式依然需要向 CPU 申请权限,且需要借助 DMA 总线来完成数据的复制操作,如果 DMA 总线过多,就会造成总线冲突。
      • 读取和写入数据都要通过 Channel,Channel有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。
      • 由于 Channel 是双向的,所以读、写可以同时进行。
    • 多路复用器(Selector):
      • 用于检查一个或多个 NIO Channel 的状态是否处于可读、可写
      • Selector 是基于事件驱动实现的,我们可以在 Selector 中注册 accpet、read 监听事件,Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个 Channel 就处于就绪状态,然后进行 I/O 操作。
      • 一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。我们可以在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O 操作时,该线程就不会一直等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。
      • 目前操作系统的 I/O 多路复用机制都使用了 epoll,相比传统的 select 机制,epoll 没有最大连接句柄 1024 的限制。所以 Selector 在理论上可以轮询成千上万的客户端。

Selector 使用了五类网络I/O模型中的 I/O 复用模型。Java 中的 Selector 其实就是 select/poll/epoll 的外包类。
JDK1.5开始引入了epoll基于事件响应机制来优化NIO。如果程序运行在 Linux 操作系统,且内核版本在 2.6 以上,NIO 中会选择 epoll 来替代传统的 select/poll,这也极大地提升了 NIO 通信的性能。

NIO2

JDK1.7 发布了 NIO2,也就是 AIO。提出了从操作系统层面实现的异步 I/O,直接将 I/O 操作交给操作系统进行异步处理。

AIO作为异步非阻塞模型,理论上来说应该被广泛使用,但大多数公司并没有使用AIO,而是使用了netty,为什么?
(1)首先AIO得底层实现仍使用Epoll,并没有很好的实现异步,在性能上对比NIO没有太大优势
(2)其次AIO的代码逻辑比较复杂,且Linux上AIO还不够成熟
(3)Netty在NIO上做了很多异步的封装,是异步非阻塞框架

BIO、NIO、AIO的对比:
在这里插入图片描述

Reactor模型

Reactor 模型是同步 I/O 事件处理的一种常见模型,其核心思想是将 I/O 事件注册到多路复用器上,一旦有 I/O 事件触发,多路复用器就会将事件分发到事件处理器中,执行就绪的 I/O 事件操作。该模型有以下三个主要组件:

  • 事件接收器 Acceptor:主要负责接收请求连接;
  • 事件分离器 Reactor:接收请求后,会将建立的连接注册到分离器中,依赖于循环监听多路复用器 Selector,一旦监听到事件,就会将事件 dispatch 到事件处理器;
  • 事件处理器 Handlers:事件处理器主要是完成相关的事件处理,比如读写 I/O 操作。

(1)单线程 Reactor 线程模型:

最开始 NIO 是基于单线程实现的,所有的 I/O 操作都是在一个 NIO 线程上完成。由于 NIO 是非阻塞 I/O,理论上一个线程可以完成所有的 I/O 操作。

读写 I/O 操作时用户进程还是处于阻塞状态,这种方式在高负载、高并发的场景下会存在性能瓶颈,一个 NIO 线程如果同时处理上万连接的 I/O 操作,系统是无法支撑这种量级的请求的。

在这里插入图片描述
(2)多线程 Reactor 线程模型:

在 Tomcat 和 Netty 中都使用了一个 Acceptor 线程来监听连接请求事件,当连接成功之后,会将建立的连接注册到多路复用器中,一旦监听到事件,将交给 Worker 线程池来负责处理。大多数情况下,这种线程模型可以满足性能要求,但如果连接的客户端再上一个量级,一个 Acceptor 线程可能会存在性能瓶颈。

在这里插入图片描述
(3)主从 Reactor 线程模型:

现在主流通信框架中的 NIO 通信框架都是基于主从 Reactor 线程模型来实现的。在这个模型中,Acceptor 不再是一个单独的 NIO 线程,而是一个线程池。Acceptor 接收到客户端的 TCP 连接请求,建立连接之后,后续的 I/O 操作将交给 Worker I/O 线程。

在这里插入图片描述

基于线程模型的 Tomcat 参数调优

Tomcat 中,BIO、NIO 是基于主从 Reactor 线程模型实现的。

(1)在 BIO 中,Tomcat 中的 Acceptor 只负责监听新的连接,一旦连接建立监听到 I/O 操作,将会交给 Worker 线程中,Worker 线程专门负责 I/O 读写操作。

(2)在 NIO 中,Tomcat 新增了一个 Poller 线程池,Acceptor 监听到连接后,不是直接使用 Worker 中的线程处理请求,而是先将请求发送给了 Poller 缓冲队列。在 Poller 中,维护了一个 Selector 对象,当 Poller 从队列中取出连接后,注册到该 Selector 中;然后通过遍历 Selector,找出其中就绪的 I/O 操作,并使用 Worker 中的线程处理相应的请求。

在这里插入图片描述

设置 Acceptor 线程池和 Worker 线程池的配置项:

  • acceptorThreadCount:该参数代表 Acceptor 的线程数量,在请求客户端的数据量非常巨大的情况下,可以适当地调大该线程数量来提高处理请求连接的能力,默认值为 1。
  • maxThreads:专门处理 I/O 操作的 Worker 线程数量,默认是 200,可以根据实际的环境来调整该参数,但不一定越大越好。
  • acceptCount:Tomcat 的 Acceptor 线程是负责从 accept 队列中取出该 connection,然后交给工作线程去执行相关操作,这里的 acceptCount 指的是 accept 队列的大小。
  • keep alive:当 Http 关闭 keep alive,在并发量比较大时,可以适当地调大这个值。而在 Http 开启 keep alive 时,因为 Worker 线程数量有限,Worker 线程就可能因长时间被占用,而连接在 accept 队列中等待超时。如果 accept 队列过大,就容易浪费连接。
  • maxConnections:表示有多少个 socket 连接到 Tomcat 上。在 BIO 模式中,一个线程只能处理一个连接,一般 maxConnections 与 maxThreads 的值大小相同;在 NIO 模式中,一个线程同时处理多个连接,maxConnections 应该设置得比 maxThreads 要大的多,默认是 10000。

Java序列化

  • 序列化:将对象的状态转换为字节流,以便存储到文件或通过网络传输。
  • 反序列化:将字节流还原为对象。

好处:Java 默认的序列化是通过 Serializable 接口实现的,只要类实现了该接口,同时生成一个默认的版本号,我们无需手动设置,该类就会自动实现序列化与反序列化。

实现原理

Java 提供了一种序列化机制,这种机制能够将一个对象序列化为二进制形式(字节数组),用于写入磁盘或输出到网络,同时也能从网络或磁盘中读取字节数组,反序列化成对象,在程序中使用。
在这里插入图片描述
(1)JDK 提供的两个输入、输出流对象 ObjectInputStream 和 ObjectOutputStream,只能对实现了 Serializable 接口的类的对象进行反序列化和序列化
(2)仅对对象的非 transient 的实例变量进行序列化,而不会序列化对象的 transient 的实例变量,也不会序列化静态变量。

缺陷

(1)无法跨语言:Java 序列化目前只适用基于 Java 语言实现的框架。
(2)易被攻击:在反序列化过程中,ObjectInputStream 的 readObject() 方法会自动调用目标类的构造函数、静态初始化块以及某些特定方法(如 readObject 或 readResolve)。如果攻击者能够控制输入的字节流,就可以触发恶意代码执行。

攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。
很多序列化协议都制定了一套数据结构来保存和获取对象。例如,JSON 序列化、ProtocolBuf 等,它们只支持一些基本类型和数组数据类型,这样可以避免反序列化创建一些不确定的实例。虽然它们的设计简单,但足以满足当前大部分系统的数据传输需求。

(3)序列化后的流太大:Java 序列化实现的二进制编码完成的二进制数组大小,比 ByteBuffer 实现的二进制编码完成的二进制数组大小要大上几倍。
(4)序列化性能太差: Java 序列化中的编码耗时要比 ByteBuffer 长很多。

解决方案

目前业内优秀的序列化框架有很多,而且大部分都避免了 Java 默认序列化的一些缺陷。例如,最近几年比较流行的 FastJson、Kryo、Protobuf、Hessian 等。

推荐使用Protobuf

Protocol Buffers (Protobuf) 是由 Google 开发的一种轻量级、高效的数据序列化框架,广泛应用于分布式系统中的数据传输和存储。它具有以下特点:

  • 高性能:编解码速度快,适合高频次的数据交换场景。
  • 高压缩比:生成的二进制数据体积小,节省存储空间和网络带宽。
  • 多语言支持:支持 Java、Python、C++、Go 等多种编程语言,便于跨平台开发。
  • 强类型定义:通过 .proto 文件明确字段名称和类型,避免了 JSON 等弱类型格式的歧义性。

核心概念:

(1) .proto 文件:Protobuf 使用 .proto 文件定义数据结构。开发者在 .proto 文件中声明字段名称和类型,然后通过工具生成对应语言的代码。

syntax = "proto3"; // 使用 proto3 语法

message Person {
    int32 id = 1; // 唯一标识用户的 ID
    string name = 2; // 用户的名字
    string email = 3; // 用户的邮箱
}

(2)字段标识符 (Tag)

每个字段都有一个唯一的正整数标识符(Tag),用于区分不同的字段。在序列化时,字段名会被替换为 Tag,从而减少冗余信息。

message Person {
    int32 id = 1;
    string name = 2;
}

在序列化时,字段名 id 和 name 不会被存储,而是用 Tag 1 和 2 替代。

存储格式:

在这里插入图片描述

Protobuf 使用一种紧凑的 T-L-V(Tag-Length-Value)格式存储数据:

  • T (Tag):字段的唯一标识符。
  • L (Length):字段值的长度(对于某些类型不需要存储长度)。
  • V (Value):字段值经过编码后的值。

(1) Varint 编码

Varint 是一种变长编码方式,用于高效存储整数类型数据。它的特点是:

  • 每个字节的最后一位作为标志位(msb),表示是否还有后续字节。
    • 0 表示当前字节是最后一个字节。
    • 1 表示后面还有字节。
  • 对于较小的整数,可以只用 1 个字节存储。

例如:

  • 数字 1 只需要 1 个字节:0000 0001
  • 数字 300 需要 2 个字节:1010 1100 0000 0010

(2) Zigzag 编码

Zigzag 编码用于高效存储负数。它的原理是将负数映射为非负数,从而避免浪费额外的字节。

例如:

  • -1 被编码为 1
  • -2 被编码为 3
  • 0 被编码为 0

在 Protobuf 中,使用 sint32 和 sint64 类型来表示带符号整数,底层会自动进行 Zigzag 编码。


这是一个使用单例模式实现的类,如果我们将该类实现 Java 的 Serializable 接口,它还是单例吗?

java">public class Singleton implements Serializable{
 
    private final static Singleton singleInstance = new Singleton();
 
    private Singleton(){}
 
    public static Singleton getInstance(){
       return singleInstance; 
    }
}

当对象被序列化后,反序列化会调用类的无参构造函数(即使它是私有的),从而可能创建一个新的实例,导致单例模式失效

解决方案:

重写 readResolve 方法,在反序列化时替换返回的对象。

java">public class Singleton implements Serializable {

    private final static Singleton singleInstance = new Singleton();

    // 私有构造函数,防止外部实例化
    private Singleton() {}

    // 获取单例实例的方法
    public static Singleton getInstance() {
        return singleInstance;
    }

    // 防止反序列化破坏单例模式
    private Object readResolve() {
        return singleInstance; // 始终返回原始实例
    }
}

RPC

微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信的桥梁,服务治理则提供了服务的后勤保障。所以,我们在做技术选型时,更多要考虑的是这两个核心的需求。

目前,很多微服务框架中的服务通信是基于 RPC 通信实现的,在没有进行组件扩展的前提下,SpringCloud 是基于 Feign 组件实现的 RPC 通信(基于 Http+Json 序列化实现)Dubbo 是基于 SPI 扩展了很多 RPC 通信框架,包括 RMI、Dubbo、Hessian 等 RPC 通信框架(默认是 Dubbo+Hessian 序列化)

RPC(Remote Process Call),即远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC 框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用 RPC 服务同调用本地方法一样。正因为这种方便、透明的远程调用,RPC 被广泛应用于当下企业级以及互联网项目中,是实现分布式系统的核心。

更推荐使用 Dubbo 实现的这一套 RPC 通信协议。Dubbo 协议是建立的单一长连接通信,网络 I/O 为 NIO 非阻塞读写操作,更兼容了 Kryo、FST、Protobuf 等性能出众的序列化框架,在高并发、小对象传输的业务场景中非常实用。

RMI

RMI(Remote Method Invocation)是 JDK 中最先实现了 RPC 通信的框架之一,实现了一台虚拟机应用对远程方法的调用可以同对本地方法的调用一样,很多开源的 RPC 通信框架也是基于 RMI 实现原理设计出来的,包括 Dubbo 框架中也接入了 RMI 框架。目前 RMI 已经很成熟地应用在了 EJB 以及 Spring 框架中,是纯 Java 网络分布式应用系统的核心解决方案。

在这里插入图片描述
RMI 在高并发场景下的性能瓶颈:

  • Java默认序列化:RMI 的序列化采用的是 Java 默认的序列化方式。
  • TCP短连接:RMI 是基于 TCP 短连接实现,在高并发情况下,大量请求会带来大量连接的创建和销毁,这对于系统来说无疑是非常消耗性能的。
  • 阻塞式网络 I/O。

RPC优化路径

(1)选择合适的通信协议:为了保证数据传输的可靠性,通常情况下我们会采用 TCP 协议。如果在局域网且对数据传输的可靠性没有要求的情况下,我们也可以考虑使用 UDP 协议,毕竟这种协议的效率要比 TCP 协议高。

(2)使用单一长连接:服务之间的通信,连接的消费端不会像客户端那么多,但消费端向服务端请求的数量却一样多,我们基于长连接实现,就可以省去大量的 TCP 建立和关闭连接的操作,从而减少系统的性能消耗,节省时间。

(3)优化 Socket 通信:使用比较成熟的通信框架,比如 Netty。Netty4 对 Socket 通信编程做了很多方面的优化:

  • 实现非阻塞 I/O
  • 高效的 Reactor 线程模型
  • 串行设计:服务端在接收消息之后,存在着编码、解码、读取和发送等链路操作。如果这些操作都是基于并行去实现,无疑会导致严重的锁竞争,进而导致系统的性能下降。为了提升性能,Netty 采用了串行无锁化完成链路操作,Netty 提供了 Pipeline 实现链路的各个操作在运行期间不进行线程切换。
  • 零拷贝: NIO 提供的 ByteBuffer 可以使用 Direct Buffers 模式,直接开辟一个非堆物理内存,不需要进行字节缓冲区的二次拷贝,可以直接将数据写入到内核空间。
  • 针对套接字编程提供的一些 TCP 参数配置项,提高网络吞吐量:Netty 可以基于 ChannelOption 来设置这些参数。
    • TCP_NODELAY:TCP_NODELAY 选项是用来控制是否开启 Nagle 算法。Nagle 算法通过缓存的方式将小的数据包组成一个大的数据包,从而避免大量的小数据包发送阻塞网络,提高网络传输的效率。我们可以关闭该算法,优化对于时延敏感的应用场景。
    • SO_RCVBUF 和 SO_SNDBUF:可以根据场景调整套接字发送缓冲区和接收缓冲区的大小
    • SO_BACKLOG:backlog 参数指定了客户端连接请求缓冲队列的大小。服务端处理客户端连接请求是按顺序处理的,所以同一时间只能处理一个客户端连接,当有多个客户端进来的时候,服务端就会将不能处理的客户端连接请求放在队列中等待处理。
    • SO_KEEPALIVE:当设置该选项以后,连接会检查长时间没有发送数据的客户端的连接状态,检测到客户端断开连接后,服务端将回收该连接。我们可以将该时间设置得短一些,来提高回收连接的效率。

(4)量身定做报文格式:设计一套报文,用于描述具体的校验、操作、传输数据等内容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。

参考数据格式:

在这里插入图片描述
在这里插入图片描述

(5)编码、解码:对于实现一个好的网络通信协议来说,兼容优秀的序列化框架是非常重要的。如果只是单纯的数据对象传输,我们可以选择性能相对较好的 Protobuf 序列化,有利于提高网络通信的性能。

(6)调整 Linux 的 TCP 参数设置选项:如果 RPC 是基于 TCP 短连接实现的,我们可以通过修改 Linux TCP 配置项来优化网络通信。

我们可以通过 sysctl -a | grep net.xxx 命令运行查看 Linux 系统默认的的 TCP 参数设置,如果需要修改某项配置,可以通过编辑 vim/etc/sysctl.conf,加入需要修改的配置项, 并通过 sysctl -p 命令运行生效修改后的配置项设置。通常我们会通过修改以下几个配置项来提高网络吞吐量和降低延时。

在这里插入图片描述

📌 [ 笔者 ]   文艺倾年
📃 [ 更新 ]   2025.2.17
❌ [ 勘误 ]   /* 暂无 */
📜 [ 参考 ]  《极客时间》刘超老师的课堂内容,用于后期复习回忆。

在这里插入图片描述


http://www.niftyadmin.cn/n/5862511.html

相关文章

DuodooBMS源码解读之 cncw_ledger模块

Odoo 18 扩展模块用户使用手册 一、模块概述 本扩展模块是基于 Odoo 18 开发的&#xff0c;主要涉及账务相关的功能扩展&#xff0c;包括付款、收款、日记账报表处理、账户明细导出、对账单操作等功能。以下将详细介绍各个模块的使用方法。 二、模块功能及操作步骤 &#x…

Java集合框架大师课:从青铜到王者的数据结构指南(一)

&#x1f680; Java集合框架大师课&#xff1a;从青铜到王者的数据结构指南&#xff08;一&#xff09; &#x1f31f; 系列定位&#xff1a;全网最懂小白的JCF实战教程 | 建议搭配IDE边学边练 &#x1f3af; 学习路线图 第一章&#xff1a;初识JCF江湖 1.1 什么是JCF&#xf…

深入解析TLS协议:保障网络通信安全的关键技术

深入解析TLS协议&#xff1a;保障网络通信安全的关键技术 在当今信息化社会&#xff0c;网络安全已成为全球关注的焦点。随着技术的进步&#xff0c;尤其是互联网的普及&#xff0c;网络攻击呈现多样化、复杂化的趋势。为了保护网络系统的安全&#xff0c;研究人员和安全专家将…

Java 使用websocket

添加依赖 <!-- WebSocket 支持 --> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId> </dependency>添加配置类 Configuration public class WebSocketConfig {B…

C语言学习【1】C语言关于寄存器的封装

目录 1.封装寄存的C语言的语法volatile&#xff1a;unsigned int:*pGpiobOdrvolatile unsigned int * 2.进一步C语言的封装 在嵌入式中&#xff0c;底层一定是操作寄存器&#xff0c;我有一个理念&#xff0c;凡事一定要想清楚&#xff0c;把任何知识点融入自己的理解之中&…

MinkowskiEngine安装(CUDA11.8+torch2.0.1+RTX4070TI)

1、背景 1&#xff09;因为项目要用这个库&#xff1a;MinkowskiEngine&#xff0c;Minkowski Engine — MinkowskiEngine 0.5.3 documentation 然后就用了之前安装好 MinkowskiEngine 的torch1.8.1,cuda11.1的环境。 2&#xff09;自己的代码出现cuda不支持torch用gpu进行矩…

2024系统编程语言风云变幻:Rust持续领跑,Zig与Ada异军突起

2024年系统编程语言调查报告新鲜出炉&#xff01;这份报告对Rust、Zig、Ada、C、C等主流语言进行了全面评估&#xff0c;结果令人瞩目。Rust凭借其强大的类型系统和内存安全机制继续领跑&#xff0c;而Zig和Ada则展现出巨大的潜力&#xff0c;为系统编程领域带来了新的活力。本…

有没有使用wxpython开发的类似于visio或drawio的开源项目(AI生成)

有没有使用wxpython开发的类似于visio或drawio的开源项目 是的&#xff0c;有一些使用wxPython开发的类似于Microsoft Visio或draw.io&#xff08;现为diagrams.net&#xff09;的开源项目。wxPython 是一个跨平台的GUI工具包&#xff0c;它允许Python开发者创建桌面应用程序&…