Java编程性能调优

原文:https://time.geekbang.org/column/article/97215

String字符串优化

String的版本变更:
在这里插入图片描述
offset::偏移量
count:字符数量
1.Java7和8版本中:
String不再有offset和count两个变量,使String对象占用的内存稍微少了些。
String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。
2.Java9开始
工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性 coder,它是一个编码格式的标识。
为了节约内存空间,于是使用了占 8 位,1 个字节的 byte 数组来存放字符串。
coder 的作用是,在计算字符串长度或者使用indexOf()函数时,我们需要根据这个字段,判断如何计算字符串长度。coder 属性默认有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16。如果 String 判断字符串只包含了 Latin-1,则 coder 属性值为 0,反之则为1。

String 对象的不可变性
第一,保证 String 对象的安全性。假设 String 对象是可变的,那么 String对象将可能被恶意修改。
第二,保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap容器才能实现相应的 key-value 缓存功能。
第三,可以实现字符串常量池。

String 对象的优化
1.如何构建超大字符串
当在程序使用+号作为字符串的拼接时,会被编译器优化成StringBuilder的方式。
显式使用StringBuilder
2. 使用 String.intern 节省内存
具体做法就是,在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。
3.使用字符串的分割方法
Split() 方法使用了正则表达式实现了其强大的分割功能,而正则表达式的性能是非常不稳定的。
可以使用String.indexOf() 方法代替 Split()方法完成字符串的分割。

intern()
//个人理解
先在字符常量中查找,如果存在,返回指向该字符常量中对象的引用,如果不存在,创建,并返回指向该字符常量中的引用。

慎重使用正则表达式
如:split()

在这里插入图片描述
正则表达式是一个用正则符号写出的公式,程序对这个公式进行语法分析,建立一个语法分析树,再根据这个分析树结合正则表达式的引擎生成执行程序(这个执行程序我们把它称作状态机,也叫状态自动机),用于字符匹配。
实现正则表达式引擎的方式有两种:DFA 自动机(确定有限状态自动机)和 NFA 自动机(非确定有限状态自动机)。

如果用 DFA 自动机作为正则表达式引擎,则匹配的时间复杂度为 O(n);
如果用 NFA 自动机(支持更多功能)作为正则表达式引擎(默认),由于 NFA 自动机在匹配过程中存在大量的分支和回溯,假设 NFA 的状态数为 s(这里的状态数指不同的匹配格式),时间复杂度为 O(ns)。

NFA匹配规则:
读取每一个字符,拿去和目标字符串匹配,匹配成功就换正则表达式的下一个字符,反之继续和目标字符的下一个字符进行匹配。

NFA自动机的回溯
FA 自动机实现的比较复杂的正则表达式,在匹配过程中经常会引起回溯问题(贪婪模式)。占用CPU的资源较大。

  1. 贪婪模式(Greedy)
    在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。
  2. 懒惰模式(Reluctant)(如:?)
    在该模式下,正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。
  3. 独占模式(Possessive)
    同贪婪模式一样,独占模式一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。

正则表达式的调优:
1.少用贪婪模式,多用独占模式
2. 减少分支选择
分支选择类型“(X|Y|Z)”的正则表达式会降低性能/。可以考虑将常用的放在前面,能因式分解的需要提取出来((abcd|abef)–>ab(cd|ef))
3. 减少捕获嵌套
捕获组是指把正则表达式中,子表达式匹配的内容保存到以数字编号或显式命名的数组中,方便后面引用。一般一个 () 就是一个捕获组,捕获组可以进行嵌套。
在正则表达式中,每个捕获组都有一个编号,编号 0 代表整个匹配到的内容。


public static void main( String[] args )
{
	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));//(.*?)
		System.out.println(m.group(3));//(</input>)
	}
}

//结果
<input high=\"20\" weight=\"70\">test</input>
<input high=\"20\" weight=\"70\">
test
</input>

非捕获组则是指参与匹配却不进行分组编号的捕获组,其表达式一般由(?:exp)组成。
如果你并不需要获取某一个分组内的文本,那么就使用非捕获分组。

public static void main( String[] args )
{
	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 high=\"20\" weight=\"70\">test</input>
test

ArrayList和LinkedList的选择

ArrayList

ArrayList的elementData修饰了transient;

  // 默认初始化容量
    private static final int DEFAULT_CAPACITY = 10;
    // 对象数组
    transient Object[] elementData;//用来存储数据的 ,不可被序列化
    // 数组长度
    private int size;

但是ArrayList又实现了Serializable接口,这是为什么呢?

由于 ArrayList 的数组是基于动态扩增的,所以并不是所有被分配的内存空间都存储了数据。
如果采用外部序列化法实现数组的序列化,会序列化整个数组。ArrayList 为了避免这些没有存储数据的内存空间被序列化,内部提供了两个私有方法 writeObject 以及 readObject 来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。
因此使用 transient 修饰数组,是防止对象数组被其他外部方法序列化。

ArrayList 构造函数
1.空数组对象
2.初始化值
3.集合类型进行初始化

如果确定容量大小,可以传入初始化值,有助于减少数组的扩容次数,从而提高系统性能。

 public ArrayList(int initialCapacity) {
        // 初始化容量不为零时,将根据初始化值创建数组大小
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {// 初始化容量为零时,使用默认的空数组
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

    public ArrayList() {
        // 初始化默认为空数组
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

ArrayList的新增元素

 public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!//确保容量够大
        elementData[size++] = e;
        return true;
    }

    public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

  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);
    }

如果我们在初始化时就比较清楚存储数据的大小,就可以在 ArrayList 初始化时指定数组容量大小,并且在添加元素时,只在数组末尾添加元素,那么 ArrayList 在大量新增元素的场景下,性能并不会变差,反而比其他 List 集合的性能要好。

ArrayList的删除元素
每次删除都要进行数组重组

 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;
    }

ArrayList 遍历元素
根据索引查找非常快速,如果寻找某一特定元素,需要遍历

  public E get(int index) {
        rangeCheck(index);

        return elementData(index);
    }

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

LinkedList

 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.next = next;
            this.prev = prev;
        }
    }

原本在JDK1.7,LinkedList 中只包含了一个 Entry 结构的 header 属性,并在初始化的时候默认创建一个空的 Entry,用来做 header,前后指针指向自己,形成一个循环双向链表。
在 JDK1.7 之后,LinkedList 做了很大的改动,对链表进行了优化。链表的 Entry结构换成了 Node,内部组成基本没有改变,但 LinkedList 里面的 header 属性去掉了,新增了一个 Node结构的 first 属性和一个 Node结构的 last 属性。这样做有以下几点好处:
1.first/last 属性能更清晰地表达链表的链头和链尾概念。
2.first/last 方式可以在初始化 LinkedList 的时候节省 new 一个 Entry。
3.first/last 方式最重要的性能优化是链头和链尾的插入删除操作更加快捷了。

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


//自行实现了readObject 和 writeObject 进行序列化和反序列化
  transient int size = 0;
  transient Node<E> first;
  transient Node<E> last;

LinkedList 新增元素
默认的 add (Ee) 方法是将添加的元素加到队尾,首先是将 last 元素置换到临时变量中,生成一个新的 Node 节点对象,然后将 last引用指向新节点对象,之前的 last 对象的前指针指向新节点对象

//默认添加到队尾
 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++;
    }


//添加到任意位置
 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++;
    }

LinkedList 删除元素
循环遍历

LinkedList 遍历元素
每次循环都会遍历半个List
所以在 LinkedList 循环遍历时,我们可以使用iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List。
所以使用for循环遍历效率会特低,建议使用迭代器。

在这里插入图片描述
1.添加到头部
ArrayList需要重排,效率低,而LinkedList因为会从头部或者尾部遍历插入,所以效率高。
2.添加到中部
ArrayList也需要部分重排,但是LinkedList因为需要遍历到中部,所以效率低。
3.添加到尾部
ArrayList不需要重排,效率高,虽然LinkedList也不要遍历(直接在尾部开始遍历找到)但 LinkedList 中多了 new对象以及变换指针指向对象的过程,所以效率要低于ArrayList。

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

Stream

与字节流的概念不太一样,Java8 集合中的 Stream相当于高级版的 Iterator,他可以通过 Lambda 表达式对集合进行各种非常便利、高效的聚合操作,或者大批量数据操作。
Stream 的聚合操作与数据库SQL 的聚合操作 sorted、filter、map 等类似。我们在应用层就可以高效地实现类似数据库 SQL 的聚合操作了,而在数据操作方面,Stream不仅可以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量数据,提高数据的处理效率。

如:过滤分组一所中学里身高在 160cm 以上的男女同学

//传统做法
Map<String, List<Student>> stuMap = new HashMap<String, List<Student>>();
        for (Student stu: studentsList) {
            if (stu.getHeight() > 160) { // 如果身高大于 160
                if (stuMap.get(stu.getSex()) == null) { // 该性别还没分类
                    List<Student> list = new ArrayList<Student>(); // 新建该性别学生的列表
                    list.add(stu);// 将学生放进去列表
                    stuMap.put(stu.getSex(), list);// 将列表放到 map 中
                } else { // 该性别分类已存在
                    stuMap.get(stu.getSex()).add(stu);// 该性别分类已存在,则直接放进去即可
                }
            }
        }

//Stream
//串行实现
Map<String, List<Student>> stuMap = stuList.stream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex)); 
//并行实现
Map<String, List<Student>> stuMap = stuList.parallelStream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex)); 

Stream 中间操作(懒操作)
中间操作只对操作进行了记录,即只会返回一个流,不会进行计算操作。
中间操作又可以分为无状态(Stateless)与有状态(Stateful)操作,前者是指元素的处理不受之前元素的影响,后者是指该操作只有拿到所有元素之后才能继续下去。

Stream 终结操作
终结操作又可以分为短路(Short-circuiting)与非短路(Unshort-circuiting)操作,前者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得到最终结果。

在这里插入图片描述
BaseStream 和 Stream 为最顶端的接口类。BaseStream 主要定义了流的基本接口方法,例如,spliterator、isParallel 等
Stream 则定义了一些流的常用操作方法,例如,map、filter 等。
ReferencePipeline 是一个结构类,他通过定义内部类组装了各种操作流。他定义了 Head、StatelessOp、StatefulOp 三个内部类,实现了 BaseStream 与 Stream 的接口方法。
Sink 接口是定义每个 Stream 操作之间关系的协议,他包含 begin()、end()、cancellationRequested()、accpt() 四个方法。ReferencePipeline 最终会将整个 Stream 流操作组装成一个调用链,而这条调用链上的各个Stream 操作的上下关系就是通过 Sink 接口协议来定义实现的。

在这里插入图片描述

Stream 操作叠加
一个 Stream 的各个操作是由处理管道组装,并统一完成数据处理的。
管道结构通常是由 ReferencePipeline 类实现的

Head类
主要用来定义数据源操作,在我们初次调用 names.stream() 方法时,会初次加载 Head 对象,此时为加载数据源操作;接着加载的是中间操作,分别为无状态中间操作 StatelessOp 对象和有状态操作 StatefulOp 对象,此时的 Stage 并没有执行,而是通过 AbstractPipeline 生成了一个中间操作 Stage 链表;当我们调用终结操作时,会生成一个最终的 Stage,通过这个 Stage 触发之前的中间操作,从最后一个 Stage 开始,递归产生一个 Sink 链。
在这里插入图片描述

如:
查找出一个长度最长,并且以张为姓氏的名字。

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

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

步骤:
1.因为是ArrayList,所以会调用集合类基础接口 Collection 的 Stream 方法。
2.然后,Stream 方法就会调用 StreamSupport 类的 Stream 方法,方法中初始化了一个 ReferencePipeline 的 Head 内部类对象:
3.再调用filter和map方法,这两个方法都是无状态的中间操作,所以执行filter和map操作时,并没有进行任何的操作。而是分别创建了一个Stage来标识用户的每一次操作。
通常情况下Stream 的操作又需要一个回调函数,所以一个完整的 Stage 是由数据来源、操作、回调函数组成的三元组来表示。

  @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));
                    }
                };
            }
        };
    }

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

        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;
    }

因为在创建每一个 Stage 时,都会包含一个 opWrapSink() 方法,该方法会把一个操作的具体实现封装在 Sink 类中,Sink 采用(处理 -> 转发)的模式来叠加操作。

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

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

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

  @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;
    }

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

 @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);
        }
    }

Java8 中的 Spliterator 的 forEachRemaining 会迭代集合,每迭代一次,都会执行一次 filter 操作,如果 filter 操作通过,就会触发 map 操作,然后将结果放入到临时数组 object 中,再进行下一次的迭代。完成中间操作后,就会触发终结操作 max。

Stream 并行处理
要实现并行处理,我们只需要在例子的代码中新增一个 Parallel() 方法。

如:

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

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

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

 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 方法会估算出分片的数据量。
通过预估的数据量获取最小处理单元的阀值,如果当前分片大小大于最小处理单元的阀值,就继续切分集合。每个分片将会生成一个 Sink 链表,当所有的分片操作完成后,ForkJoin 框架将会合并分片任何结果集。

在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;在单核 CPU 服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核 CPU的情况下,Stream 的并行迭代优势明显。所以我们在平时处理大数据的集合时,应该尽量考虑将应用部署在多核 CPU 环境下,并且使用 Stream的并行迭代方式进行处理。

在这里插入图片描述

HashMap 的性能优化

面对哈希冲突:使用链地址法。即数组+链表

//HashMap是一个由Node数组构成,每个Node包含了一个key-value的键值对
  transient Node<K,V>[] table;
  int threshold;		//边界值
  final float loadFactor;  //加载因子


static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;//出现哈希冲突后,指针指向新增的Node

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

LoadFactor 属性是用来间接设置 Entry 数组(哈希表)的内存空间大小,在初始 HashMap 不设置参数的情况下,默认 LoadFactor 值为 0.75。
Threshold 是通过初始容量和LoadFactor 计算所得,默认为16

HashMap 添加元素优化
当程序将一个 key-value 对添加到 HashMap中,程序首先会根据该 key 的 hashCode()返回值,再通过 hash() 方法计算出 hash 值,再通过 putVal 方法中的 (n - 1) &hash 决定该 Node 的存储位置。

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

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

  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);


在这里插入图片描述

 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;
    }


HashMap 获取元素优化
出现哈希冲突时,因为引入了红黑树,所以查询的平均时间复杂度降低到了O(log(n))。

网络通信优化:I/O模型

I/O 是机器获取和交换信息的主要渠道,而流是完成 I/O 操作的主要方式。

机器间或程序间在进行信息交换或者数据交换时,总是先将对象或数据转换为某种形式的流,再通过流的传输,到达指定机器或程序后,再将流转换为对象数据。因此,流就可以被看作是一种数据的载体,通过它可以实现数据交换和传输。

字节流:

在这里插入图片描述

字符流:

在这里插入图片描述

I/O 操作分为磁盘 I/O 操作和网络 I/O 操作。前者是从磁盘中读取数据源输入到内存中,之后将读取的信息持久化输出在物理磁盘上;后
者是从网络中读取信息输入到内存,最终将信息输出到网络中。

如以下的传统I/O输入操作在操作系统的流程:
在这里插入图片描述
JVM 会发出 read() 系统调用,并通过 read 系统调用向内核发起读请求;
内核向硬件发送读指令,并等待读就绪;
内核把将要读取的数据复制到指向的内核缓存中;
操作系统内核将数据复制到用户空间缓冲区,然后 read 系统调用返回

弊端:数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低 I/O的性能。

阻塞

在传统 I/O 中,InputStream 的 read()是一个 while 循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。(大量请求时,性能骤减)

1. 使用缓冲区优化读写流操作
NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer 是一块连续的内存块,是 NIO 读写数据的中转地。Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。
传统 I/O 和 NIO 的最大区别就是传统 I/O 是面向流,NIO 是面向 Buffer。Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。

2. 使用 DirectBuffer 减少内存复制
NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存。
DirectBuffer 则是直接将步骤简化为从内核空间复制到外部设备,减少了数据拷贝。
弊端:由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。

3. 避免阻塞,优化 I/O 操作
NIO 发布后,通道和多路复用器这两个基本组件实现了 NIO 的非阻塞。

通道(Channel)
Channel 有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。在 NIO 中,我们读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行。

多路复用器(Selector)
Selector 是 Java NIO 编程的基础。用于检查一个或多个 NIO Channel 的状态是否处于可读、可写。
Selector 是基于事件驱动实现的,我们可以在 Selector 中注册 accept、read监听事件,Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个 Channel 就处于就绪状态,然后进行 I/O 操作。
一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。我们可以在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O 操作时,该线程就不会一直等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。

目前操作系统的 I/O 多路复用机制都使用了 epoll,相比传统的 select 机制,epoll 没有最大连接句柄 1024 的限制。所以 Selector 在理论上可以轮询成千上万的客户端。

序列化与反序列化

SpringCloud 用的是 Jason 序列化,Dubbo 虽然兼容了 Java 序列化,,但默认使用的是 Hessian 序列化。

1.Java序列化实现原理
JDK 提供的两个输入、输出流对象 ObjectInputStream(非transient) 和 ObjectOutputStream,它们只能对实现了 Serializable 接口的类的对象进行序列化和反序列化。默认实现类是 writeObject 和 readObject,可以重写。

2.Serializable的缺陷
1.无法跨语言,只能基于java语言实现。
2.易被攻击,攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长。

Set root = new HashSet();  
Set s1 = root;  
Set s2 = new HashSet();  
for (int i = 0; i < 100; i++) {  
   Set t1 = new HashSet();  
   Set t2 = new HashSet();  
   t1.add("foo"); // 使 t2 不等于 t1  
   s1.add(t1);  
   s1.add(t2);  
   s2.add(t1);  
   s2.add(t2);  
   s1 = t1;  
   s2 = t2;   
} 

可以通过反序列化对象白名单来控制反序列化对象,重写 resolveClass 方法,并在该方法中校验对象名字。

@Override
protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundException {
if (!desc.getName().equals(Bicycle.class.getName())) {

throw new InvalidClassException(
"Unauthorized deserialization attempt", desc.getName());
}
return super.resolveClass(desc);
}

3.序列化后的流太大
Java 序列化实现的二进制编码完成的二进制数组占用空间较大。

4. 序列化性能太差
Java 序列化中的编码耗时要比 ByteBuffer 长很多。

使用 Protobuf 序列化替换 Java 序列化
Protobuf 以一个 .proto 后缀的文件为基础,这个文件描述了字段以及字段类型,通过工具可以生成不同语言的数据结构文件。在序列化该数据对象的时候,Protobuf 通过.proto 文件描述来生成 Protocol Buffers 格式的编码。

存储格式
(可以变长编码,亦可以固定长度编码)
它使用 T-L-V(标识 - 长度 - 字段值)的数据格式来存储数据,T 代表字段的正数序列 (tag),Protocol Buffers 将对象中的每个字段和正数序列对应起来,对应关系的信息是由生成的代码来保证的。在序列化的时候用整数值来代替字段名称,于是传输流量就可以大幅缩减;L 代表 Value 的字节长度,一般也只占一个字节;V 则代表字段值经过编码后的值。这种数据格式不需要分隔符,也不需要空格,同时减少了冗余字段名。

在这里插入图片描述

实现一个Java的Serializable接口的单例
因为反序列化之后,会出现多个不同的对象,那违背了单例的初衷,如何控制在反序列化的时候只有一个对象。

public class Singleton implements Serializable {
 
    private Singleton() {
    }
 
    private static final Singleton INSTANCE = new Singleton();
 
    public static Singleton getInstance() {
        return INSTANCE;
    }
 
    private void writeObject(ObjectOutputStream out) throws IOException {
        System.out.println("writeObject");
    }
 
    private void readObject(ObjectInputStream in) throws IOException,
                                                    ClassNotFoundException {
        System.out.println("readObject");
    }
 
    /**
     * writeReplace方法会在writeObject方法之前执行。
     * ObjectOutputStream会把writeReplace方法返回的对象序列化写出去。
     * 
     * @return Object
     * @throws ObjectStreamException
     */
    private Object writeReplace() throws ObjectStreamException {
        System.out.println("writeReplace");
        return INSTANCE;
    }
 
    /**
     * readResolve方法会在readObject方法之后执行,可以再次修改readObject方法返回的对象数据。
     * 这个方法是关键方法,会在反序列化的时候,直接返回该对象
     *
     * @return Object
     * @throws ObjectStreamException
     */
    private Object readResolve() throws ObjectStreamException {
        System.out.println("readResolve");
        return INSTANCE;
    }
}

网络通信优化

RPC 通信是大型服务框架的核心。
微服务的核心是远程通信和服务治理。
服务的拆分增加了通信的成本。

SpringCloud 是基于 Feign 组件实现的 RPC 通信(基于 Http+Json 序列化实现),Dubbo 是基于 SPI 扩展了很多 RPC通信框架,包括 RMI、Dubbo、Hessian 等 RPC 通信框架(默认是 Dubbo+Hessian 序列化)。

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

RPC(Remote Process Call)
远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC 框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用 RPC 服务同调用本地方法一样。

RMI(Remote Method Invocation)
JDK 中最先实现了 RPC 通信的框架之一,RMI 的实现对建立分布式 Java 应用程序至关重要,是 Java 体系非常重要的底层技术。
是纯 Java 网络分布式应用系统的核心解决方案。RMI 实现了一台虚拟机应用对远程方法的调用可以同对本地方法的调用一样,RMI帮我们封装好了其中关于远程通信的内容。

RMI 的实现原理
RMI 远程代理对象是 RMI 中最核心的组件,除了对象本身所在的虚拟机,其它虚拟机也可以调用此对象的方法。而且这些虚拟机可以不在同一个主机上,通过远程代理对象,远程应用可以用网络协议与服务进行通信。
在这里插入图片描述
RMI 在高并发场景下的性能瓶颈
1.Java 默认序列化(性能不好)
2.TCP 短连接(高并发下,大量连接的创建和销毁)
3.阻塞式网络 I/O(在Socket编程中使用了传统的I/O模型)

RPC 通信优化路径
RPC 通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作。
1. 选择合适的通信协议
TCP以及UDP
在这里插入图片描述
2. 使用单一长连接
服务之间的通信,连接的消费端不会像客户端那么多,但消费端向服务端请求的数量却一样多,我们基于长连接实现,就可以省去大量的 TCP 建立和关闭连接的操作,从而减少系统的性能消耗,节省时间。
3. 优化 Socket 通信
Netty4:
(1)实现非阻塞 I/O,多路复用器 Selector 实现了非阻塞 I/O 通信。
(2)高效的 Reactor 线程模型:Netty 使用了主从 Reactor 多线程模型,服务端接收客户端请求连接是用了一个主线程,这个主线程用于客户端的连接请求操作,一旦连接建立成功,将会监听 I/O 事件,监听到事件后会创建一个链路请求。链路请求将会注册到负责 I/O 操作的 I/O 工作线程上,由 I/O 工作线程负责后续的 I/O 操作。利用这种线程模型,可以解决在高负载、高并发的情况下,由于单个 NIO线程无法监听海量客户端和满足大量 I/O 操作造成的问题。
(3)串行设计:服务端在接收消息之后,存在着编码、解码、读取和发送等链路操作。如果这些操作都是基于并行去实现,无疑会导致严重的锁竞争,进而导致系统的性能下降。为了提升性能,Netty 采用了串行无锁化完成链路操作,Netty 提供了 Pipeline 实现链路的各个操作在运行期间不进行线程切换。
(4)零拷贝:一个数据从内存发送到网络中,存在着两次拷贝动作,先是从用户空间拷贝到内核空间,再是从内核空间拷贝到网络 I/O 中。而 NIO 提供的 ByteBuffer 可以使用 Direct Buffers 模式,直接开辟一个非堆物理内存,不需要进行字节缓冲区的二次拷贝,可以直接将数据写入到内核空间。
4. 量身定做报文格式
设计一套报文,用于描述具体的校验、操作、传输数据等内容。为了提高传输的效率,根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。
在这里插入图片描述
5. 编码、解码
如果只是单纯的数据对象传输,我们可以选择性能相对较好的 Protobuf 序列化,有利于提高网络通信的性能。
6. 调整 Linux 的 TCP 参数设置选项
可以通过编辑 vim/etc/sysctl.conf,加入需要修改的配置项, 并通过 sysctl -p命令运行生效修改后的配置项设置。
在这里插入图片描述

TCP的阻塞式I/O:

connect 阻塞
在这里插入图片描述

accept 阻塞:一个阻塞的 socket 通信的服务端接收外来连接,会调用 accept 函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。

在这里插入图片描述

read、write 阻塞:没有数据就阻塞
在这里插入图片描述

非阻塞式I/O:
使用 fcntl 可以把以上三种操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会一直被阻塞。
我们需要设置一个线程对该操作进行轮询检查
在这里插入图片描述

I/O复用
针对于非阻塞式I/O,如果使用用户线程轮询查看一个 I/O 操作的状态,在大量请求的情况下,这对于 CPU 的使用率无疑是种灾难。
进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。系统内核就可以帮我们侦测多个读操作是否处于就绪状态。

select() 函数:它的用途是,在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。
在这里插入图片描述

poll() 函数:与 select() 类似,二者在本质上差别不大。
在每次调用 select() 函数之前,系统需要把一个 fd从用户态拷贝到内核态,这样就给系统带来了一定的性能开销。

poll() 和 select() 存在一个相同的缺点,那就是包含大量文件描述符的数组被整体复制到用户态和内核的地址空间之间,而无论这些文件描述符是否就绪,他们的开销都会随着文件描述符数量的增加而线性增大。

在这里插入图片描述epoll()
在这里插入图片描述
信号驱动式 I/O
信号驱动式 I/O 类似观察者模式,内核就是一个观察者,信号回调则是通知。用户进程发起一个 I/O 请求操作,会通过系统调用 sigaction 函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关 I/O 操作。
但TCP几乎没有用到该技术这是因为 SIGIO 信号是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。

在这里插入图片描述
信号驱动式 I/O 现在被用在了 UDP 通信上,UDP 只有一个数据请求事件,这也就意味着在正常情况下UDP 进程只要捕获 SIGIO 信号,就调用 recvfrom 读取到达的数据报。如果出现异常,就返回一个异常错误。

5. 异步 I/O
真正的非阻塞I/O
当用户进程发起一个 I/O 请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知进程。这个操作包括等待数据就绪和数据从内核复制到用户空间。

在这里插入图片描述
NIO:

在 NIO 服务端通信编程中,首先会创建一个Channel,用于监听客户端连接;接着,创建多路复用器 Selector,并将 Channel 注册到 Selector,程序会通过 Selector来轮询注册在其上的 Channel,当发现一个或多个 Channel 处于就绪状态时,返回就绪的监听事件,最后程序匹配到监听事件,进行相关的 I/O 操作。

在这里插入图片描述
由于信号驱动式 I/O 对 TCP 通信的不支持,以及异步 I/O 在 Linux 操作系统内核中的应用还不大成熟,大部分框架都还是基于 I/O 复用模型实现的网络通信。

零拷贝
零拷贝是一种避免多次内存复制的技术,用来优化读写 I/O 操作。
在网络编程中,通常由 read、write 来完成一次 I/O 读写操作。每一次 I/O 读写操作都需要完成四次内存拷贝,路径是 I/O 设备 -> 内核空间 -> 用户空间-> 内核空间 -> 其它 I/O 设备。

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

Java 直接在 JVM 内存空间之外开辟了一个物理内存空间,这样内核和用户进程都能共享一份缓存数据。

线程模型优化
NIO 是基于事件驱动模型来实现的 I/O 操作。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 操作。
在这里插入图片描述
2. 多线程 Reactor 线程模型
为了解决这种单线程的 NIO 在高负载、高并发场景下的性能瓶颈,后来使用了线程池。

在 Tomcat 和 Netty 中都使用了一个 Acceptor 线程来监听连接请求事件,当连接成功之后,会将建立的连接注册到多路复用器中,一旦监听到事件,,将交给 Worker 线程池来负责处理。
在这里插入图片描述
3. 主从 Reactor 线程模型
现在主流通信框架中的 NIO 通信框架都是基于主从 Reactor 线程模型来实现的。在这个模型中,Acceptor 不再是一个单独的 NIO 线程,而是一个线程池。Acceptor 接收到客户端的 TCP 连接请求,建立连接之后,后续的 I/O 操作将交给 Worker I/O 线程。

在这里插入图片描述
基于线程模型的 Tomcat 参数调优
Tomcat 中,BIO、NIO 是基于主从 Reactor 线程模型实现的。
在 BIO 中,Tomcat 中的 Acceptor只负责监听新的连接,一旦连接建立监听到 I/O 操作,将会交给 Worker 线程中,Worker 线程专门负责 I/O 读写操作。

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

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值