😊你好,我是小航,一個正在變禿、變強的文藝傾年。
🔔本文講解【簡歷優化】性能調優 — 編程性能調優篇,期待與你一同探索、學習、進步,一起卷起來叭!
目錄
- 一、編程性能調優
- 字符串
- 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 的內存來存儲地址信息。
public class Location {private String city;private String region;private String countryCode;private double longitude;private double latitude;
}
有很多用戶在地址信息上是有重合的,比如,國家、省份、城市等。提取這部分信息為單獨一個類。【32GB -> 20GB】
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】
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 非捕獲組:
捕獲組:
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)); // testSystem.out.println(m.group(3)); // </input>
}<input high="20" weight="70">test</input>
<input high="20" weight="70">
test
</input>
非捕獲組:
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 類
,都能實現快速隨機訪問。
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
屬性:由數組長度 size、對象數組 elementData、初始化容量 default_capacity 等組成, 其中初始化容量默認大小為 10。
// 默認初始化容量
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 初始化時指定數組容量大小
。
private void ensureExplicitCapacity(int minCapacity) {modCount++;// overflow-conscious codeif (minCapacity - elementData.length > 0)grow(minCapacity);
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;private void grow(int minCapacity) {// overflow-conscious codeint 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)將元素添加到數組的末尾,在沒有發生擴容的前提下
,是不會有元素復制排序過程的。
刪除元素:🚩刪除的元素位置越靠前,數組重組的開銷就越大。
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 workreturn oldValue;
}
遍歷元素:隨機訪問
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 結構:
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:
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結構:
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屬性:
transient Node<E> first = null;
transient Node<E> last = null;
實現類:
(1)實現了 List 接口、Deque 接口
(2)繼承了 AbstractSequentialList 抽象類
(3)實現了 Cloneable 和 Serializable 接口,實現克隆和序列化。
public class LinkedList<E>extends AbstractSequentialList<E>implements List<E>, Deque<E>, Cloneable, java.io.Serializable
屬性:first、last 、size 屬性
🚩在序列化的時候不會只對頭尾
進行序列化,自行實現 readObject 和 writeObject 進行序列化與反序列化。
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
新增元素:
(1)添加到隊尾:將 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;elsel.next = newNode;size++;modCount++;
}
(2)添加到任意位置:將元素添加到任意兩個元素的中間位置,添加元素操作只會改變前后元素的前后指針,指針將會指向添加的新元素。
public void add(int index, E element) {checkPositionIndex(index);if (index == size)linkLast(element);elselinkBefore(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;elsepred.next = newNode;size++;modCount++;
}
刪除元素:
🚩要刪除的位置處于 List 的前半段,就從前往后找;若其位置處于后半段,就從后往前找。
🚩List 擁有大量元素,移除的元素又在 List 的中間段,那效率相對來說會很低。
遍歷元素:
🚩在 LinkedList 循環遍歷時,可以使用 iterator 方式迭代循環,直接拿到我們的元素,而不需要通過循環查找 List。
Stream
Java 8中,Collection 新增了兩個流方法,分別是 Stream() 和 parallelStream()。
實現原理
操作類型:
- 中間操作(Intermediate operations,還被稱為懶操作):只對操作進行了記錄,即只會返回一個流,不會進行計算操作。
- 無狀態(Stateless):元素的處理
不受之前元素的影響
。 - 有狀態(Stateful):該操作只有
拿到所有元素
之后才能繼續下去。
- 無狀態(Stateless):元素的處理
- 終結操作(Terminal operations):實現了計算操作。
- 短路(Short-circuiting):
遇到某些符合條件的元素
就可以得到最終結果。 - 非短路(Unshort-circuiting):
必須處理完所有元素
才能得到最終結果。
- 短路(Short-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 操作疊加:
調用流程分析
List<String> names = Arrays.asList(" 張三 ", " 李四 ", " 王老五 ", " 李三 ", " 劉老四 ", " 王小二 ", " 張四 ", " 張五六七 ");String maxLenStartWithZ = names.stream().filter(name -> name.startsWith(" 張 ")).mapToInt(String::length).max().toString();
(1)names.stream() 調用集合類基礎接口 Collection 的 Stream 方法
default Stream<E> stream() {return StreamSupport.stream(spliterator(), false);
}
(2)Stream 方法調用 StreamSupport 類的 Stream 方法,方法中初始化了一個 ReferencePipeline 的 Head 內部類對象:
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 采用(處理 -> 轉發)的模式來疊加操作
。
@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) {@OverrideSink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {@Overridepublic void begin(long size) {downstream.begin(-1);}@Overridepublic 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) {@OverrideSink<P_OUT> opWrapSink(int flags, Sink<R> sink) {return new Sink.ChainedReference<P_OUT, R>(sink) {@Overridepublic void accept(P_OUT u) {downstream.accept(mapper.apply(u));}};}};
}
(4)new StatelessOp 將會調用父類 AbstractPipeline 的構造函數,這個構造函數將前后的 Stage 聯系起來,生成一個 Stage 鏈表:
AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {if (previousStage.linkedOrConsumed)throw new IllegalStateException(MSG_STREAM_LINKED);previousStage.linkedOrConsumed = true;previousStage.nextStage = this;// 將當前的 stage 的 next 指針指向之前的 stagethis.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 類中。
@Override
public final Optional<P_OUT> max(Comparator<? super P_OUT> comparator) {return reduce(BinaryOperator.maxBy(comparator));
}
(6)調用 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;
}
(7)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);}
}
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 方法會估算出分片的數據量。
使用測試
(1)在循環迭代次數較少
的情況下,常規的迭代方式
性能反而更好;
(2)在單核 CPU
服務器配置環境中,常規迭代方式
更有優勢;
(3)在大數據
循環迭代中,如果服務器是多核 CPU 的情況下,Stream 的并行迭代優勢明顯。
使用一個容器裝載 100 個數字,通過 Stream 并行處理的方式將容器中為單數的數字轉移到容器 parallelList
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
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組成:
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內部類:
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方法:
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}
首先會根據該 key 的 hashCode() 返回值,再通過 hash() 方法計算出 hash 值
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
再通過 putVal 方法中的 (n - 1) & hash 決定該 Node 的存儲位置。
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整個流程:
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() 方法得到初始化的 tablen = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此處通過(n - 1) & hash 計算出的值作為 tab 的下標 i,并另 p 表示 tab[i],也就是該鏈表第一個節點的位置。并判斷 p 是否為 nulltab[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 的引用賦給 ee = p;else if (p instanceof TreeNode)
//2.1.2 現在開始了第一種情況,p 是紅黑樹節點,那么肯定插入后仍然是紅黑樹節點,所以我們直接強制轉型 p 后調用 TreeNode.putTreeVal 方法,返回的引用賦給 ee = ((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 并不包含新節點,所以判斷時要將臨界閾值減 1treeifyBin(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 keyV 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 阻塞:
- connect 阻塞:
- 非阻塞式 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 要好。
- select/poll 是
- select()函數:
- 信號驅動式 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 請求操作,會通過系統調用 sigaction 函數,給對應的套接字注冊一個信號回調,此時不阻塞用戶進程,進程會繼續工作。當內核數據就緒時,內核就為該進程生成一個 SIGIO 信號,通過信號回調通知進程進行相關 I/O 操作。
- 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 在理論上可以輪詢成千上萬的客戶端。
- 用于
- 通道(Channel):
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; // 唯一標識用戶的 IDstring 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 接口,它還是單例嗎?
public class Singleton implements Serializable{private final static Singleton singleInstance = new Singleton();private Singleton(){}public static Singleton getInstance(){return singleInstance; }
}
當對象被序列化后,反序列化會調用類的無參構造函數(即使它是私有的),從而可能創建一個新的實例,導致單例模式失效
。
解決方案:
重寫 readResolve 方法,在反序列化時替換返回的對象。
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:當設置該選項以后,
連接會檢查長時間沒有發送數據的客戶端的連接狀態,檢測到客戶端斷開連接后,服務端將回收該連接
。我們可以將該時間設置得短一些,來提高回收連接的效率。
- TCP_NODELAY:TCP_NODELAY 選項是用來控制是否開啟 Nagle 算法。Nagle 算法
(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
? [ 勘誤 ] /* 暫無 */
📜 [ 參考 ] 《極客時間》劉超老師的課堂內容,用于后期復習回憶。