久久久网中文字幕_精品国产电影自在免费观看_中文字幕电影亚洲精品_亚洲色精品Aⅴ一区区三区

?
徐州北大青鳥
當前位置: 主頁 > 學(xué)在青鳥 > 編程技巧 >

Go 調(diào)用 Java 方案和性能優(yōu)化分享

時間:2021-08-23 17:18來源:未知 作者:代碼如詩 點擊:
一個基于 Golang 編寫的日志收集和清洗的應(yīng)用需要支持一些基于 JVM 的算子。 算子依賴了一些庫: Groovy aviatorscript 該應(yīng)用有如下特征: 1、處理數(shù)據(jù)量大 a. 每分鐘處理幾百萬行日志,日志

一個基于 Golang 編寫的日志收集和清洗的應(yīng)用需要支持一些基于 JVM 的算子。

算子依賴了一些庫:

Groovy

aviatorscript

該應(yīng)用有如下特征:

1、處理數(shù)據(jù)量大

a.  每分鐘處理幾百萬行日志,日志流速幾十 MB/S;

b.  每行日志可能需要執(zhí)行多個計算任務(wù),計算任務(wù)個數(shù)不好估計,幾個到幾千都有;

c.  每個計算任務(wù)需要對一行日志進行切分/過濾,一般條件<10個;

2、有一定實時性要求,某些數(shù)據(jù)必須在特定時間內(nèi)算完;

3、4C8G 規(guī)格(后來擴展為 8C16G ),內(nèi)存比較緊張,隨著業(yè)務(wù)擴展,需要緩存較多數(shù)據(jù);

 

簡言之,對性能要求很高。

 

有兩種方案:

 

Go call Java

 

使用 Java 重寫這個應(yīng)用

 

出于時間緊張和代碼復(fù)用的考慮選擇了 "Go call Java"。

 

下文介紹了這個方案和一些優(yōu)化經(jīng)驗。

 

一  Go call Java

 

根據(jù) Java 進程與 Go 進程的關(guān)系可以再分為兩種:

方案1:JVM inside: 使用 JNI 在當前進程創(chuàng)建出一個 JVM,Go 和 JVM 運行在同一個進程里,使用 CGO + JNI 通信。

 

方案2:JVM sidecar: 額外啟動一個進程,使用進程間通信機制進行通信。

方案1,簡單測試下性能,調(diào)用 noop 方法 180萬 OPS, 其實也不是很快,不過相比方案2好很多。

這是目前CGO固有的調(diào)用代價。

 

由于是noop方法, 因此幾乎不考慮傳遞參數(shù)的代價。

方案2,比較簡單進程間通信方式是 UDS(Unix Domain Socket) based gRPC但實際測了一下性能不好, 調(diào)用 noop 方法極限5萬的OPS,并且隨著傳輸數(shù)據(jù)變復(fù)雜伴隨大量臨時對象加劇 GC 壓力。

 

不選擇方案2還有一些考慮:

 

高性能的性能通信方式可以選擇共享內(nèi)存,但共享內(nèi)存也不能頻繁申請和釋放,而是要長期復(fù)用;

 

一旦要長期使用就意味著要在一塊內(nèi)存空間上實現(xiàn)一個多進程的 malloc&free 算法;

 

使用共享內(nèi)存也無法避免需要將對象復(fù)制進出共享內(nèi)存的開銷;

 

上述性能是在我的Mac機器上測出的,但放到其他機器結(jié)果應(yīng)該也差不多。

 

出于性能考慮選擇了 JVM inside 方案。

 

1  JVM inside 原理

 

JVM inside = CGO + JNI. C 起到一個 Bridge 的作用。

2  CGO 簡介

 

是 Go 內(nèi)置的調(diào)用 C 的一種手段。詳情見官方文檔。

 

GO 調(diào)用 C 的另一個手段是通過 SWIG,它為多種高級語言調(diào)用C/C++提供了較為統(tǒng)一的接口,但就其在Go語言上的實現(xiàn)也是通過CGO,因此就 Go call C 而言使用 SWIG 不會獲得更好的性能。詳情見官網(wǎng)。

 

以下是一個簡單的例子,Go 調(diào)用 C 的 printf("hello %s\n", "world")。

 

package main

 

//// 可以這里配置一些C編譯參數(shù)

// #cgo CFLAGS:

// #cgo LDFLAGS:

/*

#include <stdlib.h>

#include <stdio.h>

 

void hello(const char* msg) {

  printf("hello %s\n", msg);

}

*/

import "C"

import "unsafe"

 

func main() {

    // 將Golang字符串轉(zhuǎn)成C風格字符串, 使用utf8編碼

    // 如果字符串本身包含 '\0' 可能會導(dǎo)致C風格字符串提前結(jié)束

  cstring := C.CString("world")

  C.hello(cstring)

    // 記得free掉, 否則內(nèi)存泄漏

  C.free(unsafe.Pointer(cstring))

}

 

運行結(jié)果輸出:

 

hello world

 

在出入?yún)⒉粡?fù)雜的情況下,CGO 是很簡單的,但要注意內(nèi)存釋放。

 

3  JNI 簡介

 

JNI 可以用于 Java 與 C 之間的互相調(diào)用,在大量涉及硬件和高性能的場景經(jīng)常被用到。JNI 包含的 Java Invocation API 可以在當前進程創(chuàng)建一個 JVM。

 

以下只是簡介JNI在本文中的使用,JNI本身的介紹略過。

 

下面是一個 C 啟動并調(diào)用 Java 的String.format("hello %s %s %d", "world", "haha", 2)并獲取結(jié)果的例子。

 

#include <stdio.h>

#include <stdlib.h>

#include "jni.h"

JavaVM *bootJvm() {

    JavaVM *jvm;

    JNIEnv *env;

 

    JavaVMInitArgs jvm_args;

    JavaVMOption options[4];

 

    // 此處可以定制一些JVM屬性

    // 通過這種方式啟動的JVM只能通過 -Djava.class.path= 來指定classpath

    // 并且此處不支持*

    options[0].optionString = "-Djava.class.path= -Dfoo=bar";

    options[1].optionString = "-Xmx1g";

    options[2].optionString = "-Xms1g";

    options[3].optionString = "-Xmn256m";

    jvm_args.options = options;

    jvm_args.nOptions = sizeof(options) / sizeof(JavaVMOption);

    jvm_args.version = JNI_VERSION_1_8;      // Same as Java version

    jvm_args.ignoreUnrecognized = JNI_FALSE; // For more error messages.

 

    JavaVMAttachArgs aargs;

    aargs.version = JNI_VERSION_1_8;

    aargs.name = "TODO";

    aargs.group = NULL;

 

    JNI_CreateJavaVM(&jvm, (void **) &env, &jvm_args);

    // 此處env對我們已經(jīng)沒用了, 所以detach掉.

    // 否則默認情況下剛create完JVM, 會自動將當前線程Attach上去

    (*jvm)->DetachCurrentThread(jvm);

    return jvm;

}

 

int main() {

    JavaVM *jvm = bootJvm();

    JNIEnv *env;

    if ((*jvm)->AttachCurrentThread(jvm, (void **) &env, NULL) != JNI_OK) {

        printf("AttachCurrentThread error\n");

        exit(1);

    }

 

    // 以下是 C 調(diào)用Java 執(zhí)行 String.format("hello %s %s %d", "world", "haha", 2) 的例子

 

    jclass String_class = (*env)->FindClass(env, "java/lang/String");

    jclass Object_class = (*env)->FindClass(env, "java/lang/Object");

    jclass Integer_class = (*env)->FindClass(env, "java/lang/Integer");

 

    jmethodID format_method = (*env)->GetStaticMethodID(env, String_class, "format",

                                                        "(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;");

    jmethodID Integer_constructor = (*env)->GetMethodID(env, Integer_class, "<init>", "(I)V");

 

    // string里不能包含中文 否則還需要額外的代碼

    jstring j_arg0 = (*env)->NewStringUTF(env, "world");

    jstring j_arg1 = (*env)->NewStringUTF(env, "haha");

    jobject j_arg2 = (*env)->NewObject(env, Integer_class, Integer_constructor, 2);

    // args = new Object[3]

    jobjectArray j_args = (*env)->NewObjectArray(env, 3, Object_class, NULL);

    // args[0] = j_arg0

    // args[1] = j_arg1

    // args[2] = new Integer(2)

    (*env)->SetObjectArrayElement(env, j_args, 0, j_arg0);

    (*env)->SetObjectArrayElement(env, j_args, 1, j_arg1);

    (*env)->SetObjectArrayElement(env, j_args, 2, j_arg2);

    (*env)->DeleteLocalRef(env, j_arg0);

    (*env)->DeleteLocalRef(env, j_arg1);

    (*env)->DeleteLocalRef(env, j_arg2);

 

    jstring j_format = (*env)->NewStringUTF(env, "hello %s %s %d");

    // j_result = String.format("hello %s %s %d", jargs);

    jobject j_result = (*env)->CallStaticObjectMethod(env, String_class, format_method, j_format, j_args);

    (*env)->DeleteLocalRef(env, j_format);

 

    // 異常處理

    if ((*env)->ExceptionCheck(env)) {

        (*env)->ExceptionDescribe(env);

        printf("ExceptionCheck\n");

        exit(1);

    }

 

    jint result_length = (*env)->GetStringUTFLength(env, j_result);

    char *c_result = malloc(result_length + 1);

    c_result[result_length] = 0;

    (*env)->GetStringUTFRegion(env, j_result, 0, result_length, c_result);

    (*env)->DeleteLocalRef(env, j_result);

 

    printf("java result=%s\n", c_result);

    free(c_result);

 

    (*env)->DeleteLocalRef(env, j_args);

    if ((*jvm)->DetachCurrentThread(jvm) != JNI_OK) {

        printf("AttachCurrentThread error\n");

        exit(1);

    }

 

    printf("done\n");

    return 0;

}

 

依賴的頭文件和動態(tài)鏈接庫可以在JDK目錄找到,比如在我的Mac上是

/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/include/jni.h

/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/server/libjvm.dylib

 

運行結(jié)果

 

java result=hello world haha 2

done

 

所有 env 關(guān)聯(lián)的 ref,會在 Detach 之后自動工釋放,但我們的最終方案里沒有頻繁 Attach&Detach,所以上述的代碼保留手動 DeleteLocalRef 的調(diào)用。否則會引起內(nèi)存泄漏(上面的代碼相當于是持有強引用然后置為 null)。

 

實際中,為了性能考慮,還需要將各種 class/methodId 緩存住(轉(zhuǎn)成 globalRef),避免每次都 Find。

 

可以看到,僅僅是一個簡單的傳參+方法調(diào)用就如此繁雜,更別說遇到復(fù)雜的嵌套結(jié)構(gòu)了。這意味著我們使用 C 來做 Bridge,這一層不宜太復(fù)雜。

 

實際實現(xiàn)的時候,我們在 Java 側(cè)處理了所有異常,將異常信息包裝成正常的 Response,C 里不用檢查 Java 異常,簡化了 C 的代碼。

 

關(guān)于Java描述符

 

使用 JNI 時,各種類名/方法簽名,字段簽名等用的都是描述符名稱,在 Java 字節(jié)碼文件中,類/方法/字段的簽名也都是使用這種格式。

除了通過 JDK 自帶的 javap 命令可以獲取完整簽名外,推薦一個 Jetbrain Intelli IDEA 的插件 jclasslib Bytecode Viewer ,可以方便的在IDE里查看類對應(yīng)的字節(jié)碼信息。

 

 

4  實現(xiàn)

 

我們目前只需要單向的 Go call Java,并不需要 Java call Go。

 

代碼比較繁雜,這里就不放了,就是上述2個簡介的示例代碼的結(jié)合體。

 

考慮 Go 發(fā)起的一次 Java 調(diào)用,要經(jīng)歷4步驟。

 

Go 通過 CGO 進入 C 環(huán)境

C 通過 JNI 調(diào)用 Java

Java 處理并返回數(shù)據(jù)給 C

C 返回數(shù)據(jù)給 Go

 

二  性能優(yōu)化

 

上述介紹了 Go call Java 的原理實現(xiàn),至此可以實現(xiàn)一個性能很差的版本。針對我們的使用場景分析性能差有幾個原因:

 

單次調(diào)用有固定的性能損失,調(diào)用次數(shù)越多損耗越大;

除了基本數(shù)據(jù)模型外的數(shù)據(jù)(主要是日志和計算規(guī)則)需要經(jīng)歷多次深復(fù)制才能抵達 Java,數(shù)據(jù)量越大/調(diào)用次數(shù)越多損耗越大;

缺少合理的線程模型,導(dǎo)致每次 Java 調(diào)用都需要 Attach&Detach,具有一定開銷;

 

以下是我們做的一些優(yōu)化,一些優(yōu)化是針對我們場景的,并不一定通用。

 

由于間隔時間有點久了, 一些優(yōu)化的量化指標已經(jīng)丟失。

 

1  預(yù)處理

 

將計算規(guī)則提前注冊到 Java 并返回一個 id, 后續(xù)使用該 id 引用該計算規(guī)則, 減少傳輸?shù)臄?shù)據(jù)量。

 

Java 可以對規(guī)則進行預(yù)處理, 可以提高性能:

 

Groovy 等腳本語言的靜態(tài)化和預(yù)編譯;

正則表達式預(yù)編譯;

使用字符串池減少重復(fù)的字符串實例;

提前解析數(shù)據(jù)為特定數(shù)據(jù)結(jié)構(gòu);

Groovy優(yōu)化

 

為了進一步提高 Groovy 腳本的執(zhí)行效率有以下優(yōu)化:

 

預(yù)編譯 Groovy 腳本為 Java class,然后使用反射調(diào)用,而不是使用 eval ;

嘗試靜態(tài)化 Groovy 腳本: 對 Groovy 不是很精通的人往往把它當 Java 來寫,因此很有可能寫出的腳本可以被靜態(tài)化,利用 Groovy 自帶的 org.codehaus.groovy.transform.sc.StaticCompileTransformation 可以將其靜態(tài)化(不包含Groovy的動態(tài)特性),可以提升效率。

自定義 Transformer 刪除無用代碼: 實際發(fā)現(xiàn)腳本里包含 打印日志/打印堆棧/打印到標準輸出 等無用代碼,使用自定義 Transformer 移除相關(guān)字節(jié)碼。

 

設(shè)計的時候考慮過 Groovy 沙箱,用于防止惡意系統(tǒng)調(diào)用( System.exit(0) )和執(zhí)行時間太長。出于性能和難度考慮現(xiàn)在沒有啟動沙箱功能。

 

動態(tài)沙箱是通過攔截所有方法調(diào)用(以及一些其他行為)實現(xiàn)的,性能損失太大。

 

靜態(tài)沙箱是通過靜態(tài)分析,在編譯階段發(fā)現(xiàn)惡意調(diào)用,通過植入檢測代碼,避免方法長時間不返回,但由于 Groovy 的動態(tài)特性,靜態(tài)分析很難分析出 Groovy 的真正行為( 比如方法的返回類型總是 Object,調(diào)用的方法本身是一個表達式,只有運行時才知道 ),因此有非常多的辦法可以繞過靜態(tài)分析調(diào)用惡意代碼。

 

2  批量化

 

減少 20%~30% CPU使用率。

 

初期,我們想通過接口加多實現(xiàn)的方式將代碼里的 Splitter/Filter 等新增一個 Java 實現(xiàn),然后保持整體流程不變。

 

比如我們有一個 Filter

 

type Filter interface {

    Filter(string) bool

}

 

除了 Go 的實現(xiàn)外,我們額外提供一個 Java 的實現(xiàn),它實現(xiàn)了調(diào)用 Java 的邏輯。

 

type JavaFilter struct {

}

 

func (f *JavaFilter) Filter(content string) bool {

  // call java

}

 

但是這個粒度太細了,流量高的應(yīng)用每秒要處理80MB數(shù)據(jù),日志切分/字段過濾等需要調(diào)用非常多次類似 Filter 接口的方法。及時我們使用了 JVM inside 方案,也無法減少單次調(diào)用 CGO 帶來的開銷。

 

另外,在我們的場景下,Go call Java 時要進行大量參數(shù)轉(zhuǎn)換也會帶來非常大的性能損失。

 

就該場景而言, 如果使用 safe 編程,每次調(diào)用必須對 content 字符串做若干次深拷貝才能傳遞到 Java。

 

優(yōu)化點:

 

將調(diào)用粒度做粗, 避免多次調(diào)用 Java: 將整個清洗動作在 Java 里重新實現(xiàn)一遍, 并且實現(xiàn)批量能力,這樣只需要調(diào)用一次 Java 就可以完成一組日志的多次清洗任務(wù)。

 

3  線程模型

 

考慮幾個背景:

 

CGO 調(diào)用涉及 goroutine 棧擴容,如果傳遞了一個棧上對象的指針(在我們的場景沒有)可能會改變,導(dǎo)致野指針;

當 Go 陷入 CGO 調(diào)用超過一段時間沒有返回時,Go 就會創(chuàng)建一個新線程,應(yīng)該是為了防止餓死其他 gouroutine 吧。

 

這個可以很簡單的通過 C 里調(diào)用 sleep 來驗證;

 

C 調(diào)用 Java 之前,當前線程必須已經(jīng)調(diào)用過 AttachCurrentThread,并且在適當?shù)臅r候DetachCurrentThread。然后才能安全訪問 JVM。頻繁調(diào)用 Attach&Detach 會有性能開銷;

 

在 Java 里做的主要是一些 CPU 密集型的操作。

 

結(jié)合上述背景,對 Go 調(diào)用 Java 做出了如下封裝:實現(xiàn)一個 worker pool,有n個worker(n=CPU核數(shù)*2)。里面每個 worker 單獨跑一個 goroutine,使用 runtime.LockOSThread() 獨占一個線程,每個 worker 初始化后, 立即調(diào)用 JNI 的 AttachCurrentThread 綁定當前線程到一個 Java 線程上,這樣后續(xù)就不用再調(diào)用了。至此,我們將一個 goroutine 關(guān)聯(lián)到了一個 Java 線程上。此后,Go 需要調(diào)用 Java 時將請求扔到 worker pool 去競爭執(zhí)行,通過 chan 接收結(jié)果。

 

由于線程只有固定的幾個,Java 端可以使用大量 ThreadLocal 技巧來優(yōu)化性能。

 

 

注意到有一個特殊的 Control Worker,是用于發(fā)送一些控制命令的,實踐中發(fā)現(xiàn)當 Worker Queue 和 n 個 workers 都繁忙的時候,控制命令無法盡快得到調(diào)用, 導(dǎo)致"根本停不下來"。

 

控制命令主要是提前將計算規(guī)則注冊(和注銷)到 Java 環(huán)境,從而避免每次調(diào)用 Java 時都傳遞一些額外參數(shù)。

 

關(guān)于 worker 數(shù)量

 

按理我們是一個 CPU 密集型動作,應(yīng)該 worker 數(shù)量與 CPU 相當即可,但實際運行過程中會因為排隊,導(dǎo)致某些配置的等待時間比較長。我們更希望平均情況下每個配置的處理耗時增高,但別出現(xiàn)某些配置耗時超高(毛刺)。于是故意將 worker 數(shù)量增加。

 

4  Java 使用 ThreadLocal 優(yōu)化

 

復(fù)用 Decoder/CharBuffer 用于字符串解碼;

 

復(fù)用計算過程中一些可復(fù)用的結(jié)構(gòu)體,避免 ArrayList 頻繁擴容;

 

每個 Worker 預(yù)先在 C 里申請一塊堆外內(nèi)存用于存放每次調(diào)用的結(jié)果,避免多次malloc&free。

 

當 ThreadLocal.get() + obj.reset() < new Obj() + expand + GC 時,就能利用 ThreadLocal來加速。

 

obj.reset() 是重置對象的代價

 

expand 是類似ArrayList等數(shù)據(jù)結(jié)構(gòu)擴容的代價

 

GC 是由于對象分配而引入的GC代價

 

大家可以使用JMH做一些測試,在我的Mac機器上:

 

ThreadLocal.get() 5.847 ± 0.439 ns/op

 

new java.lang.Object() 4.136 ± 0.084 ns/op

 

一般情況下,我們的 Obj 是一些復(fù)雜對象,創(chuàng)建的代價肯定遠超過 new java.lang.Object() ,像 ArrayList 如果從零開始構(gòu)建那么容易發(fā)生擴容不利于性能,另外熱點路徑上創(chuàng)建大量對象也會增加 GC 壓力。最終將這些代價均攤一下會發(fā)現(xiàn)合理使用 ThreadLocal 來復(fù)用對象性能會超過每次都創(chuàng)建新對象。

 

Log4j2的"0 GC"就用到了這些技巧。

由于這些Java線程是由JNI在Attach時創(chuàng)建的,不受我們控制,因此無法定制Thread的實現(xiàn)類,否則可以使用類似Netty的FastThreadLocal再優(yōu)化一把。

 

5  unsafe編程

 

減少 10%+ CPU使用率。

 

如果嚴格按照 safe 編程方式,每一步驟都會遇到一些揪心的性能問題:

 

Go 調(diào)用 C: 請求體主要由字符串數(shù)組組成,要拷貝大量字符串,性能損失很大

大量 Go 風格的字符串要轉(zhuǎn)成 C 風格的字符串,此處有 malloc,調(diào)用完之后記得 free 掉。

 

Go 風格字符串如果包含 '\0',會導(dǎo)致 C 風格字符串提前結(jié)束。

 

C 調(diào)用 Java: C 風格的字符串無法直接傳遞給 Java,需要經(jīng)歷一次解碼,或者作為 byte[] (需要一次拷貝)傳遞給 Java 去解碼(這樣控制力高一些,我們需要考慮 UTF8 GBK 場景)。

 

Java 處理并返回數(shù)據(jù)給 C: 結(jié)構(gòu)體比較復(fù)雜,C 很難表達,比如二維數(shù)組/多層嵌套結(jié)構(gòu)體/Map 結(jié)構(gòu),轉(zhuǎn)換代碼繁雜易錯。

 

C 返回數(shù)據(jù)給 Go: 此處相當于是上述步驟的逆操作,太浪費了。

 

多次實踐時候,針對上述4個步驟分別做了優(yōu)化:

 

1. Go調(diào)用C: Go 通過 unsafe 拿到字符串底層指針地址和長度傳遞給 C,全程只傳遞指針(轉(zhuǎn)成 int64),避免大量數(shù)據(jù)拷貝。

 

a. 我們需要保證字符串在堆上分配而非棧上分配才行,Go 里一個簡單的技巧是保證數(shù)據(jù)直接或間接跨goroutine引用就能保證分配到堆上。還可以參考 reflect.ValueOf() 里調(diào)用的 escape 方法。

 

b. Go的GC是非移動式GC,因此即使GC了對象地址也不會變化

 

2. C調(diào)用Java: 這塊沒有優(yōu)化,因為結(jié)構(gòu)體已經(jīng)很簡單了,老老實實寫;

 

3. Java處理并返回數(shù)據(jù)給C:

a. Java 解碼字符串:Java 收到指針之后將指針轉(zhuǎn)成 DirectByteBuffer ,然后利用 CharsetDecoder 解碼出 String。

b. Java返回數(shù)據(jù)給C:

 

1)考慮到返回的結(jié)構(gòu)體比較復(fù)雜,將其 Protobuf 序列化成 byte[] 然后傳遞回去, 這樣 C 只需要負責搬運幾個數(shù)值。

 

2)此處我們注意到有很多臨時的 malloc,結(jié)合我們的線程模型,每個線程使用了一塊 ThreadLocal 的堆外內(nèi)存存放 Protobuf 序列化結(jié)果,使用 writeTo(CodedOutputStream.newInstance(ByteBuffer))可以直接將序列化結(jié)果寫入堆外, 而不用再將 byte[] 拷貝一次。

 

3)經(jīng)過統(tǒng)計一般這塊 Response 不會太大,現(xiàn)在大小是 10MB,超過這個大小就老老實實用 malloc&free了。

4. C返回數(shù)據(jù)給Go:Go 收到 C 返回的指針之后,通過 unsafe 構(gòu)造出 []byte,然后調(diào)用 Protobuf 代碼反序列化。之后,如果該 []byte 不是基于 ThreadLocal 內(nèi)存,那么需要主動 free 掉它。

 

Golang中[]byte和string

 

代碼中的 []byte(xxxStr) 和 string(xxxBytes) 其實都是深復(fù)制。

 

type SliceHeader struct {

    // 底層字節(jié)數(shù)組的地址

  Data uintptr

    // 長度

  Len  int

    // 容量

  Cap  int

}

type StringHeader struct {

    // 底層字節(jié)數(shù)組的地址

  Data uintptr

    // 長度

  Len  int

}

 

Go 中的 []byte 和 string 其實是上述結(jié)構(gòu)體的值,利用這個事實可以做在2個類型之間以極低的代價做類型轉(zhuǎn)換而不用做深復(fù)制。這個技巧在 Go 內(nèi)部也經(jīng)常被用到,比如 string.Builder#String() 。

 

這個技巧最好只在方法的局部使用,需要對用到的 []byte 和 string的生命周期有明確的了解。需要確保不會意外修改 []byte 的內(nèi)容而導(dǎo)致對應(yīng)的字符串發(fā)生變化。

 

另外,將字面值字符串通過這種方式轉(zhuǎn)成 []byte,然后修改 []byte 會觸發(fā)一個 panic。

 

在 Go 向 Java 傳遞參數(shù)的時候,我們利用了這個技巧,將 Data(也就是底層的 void*指針地址)轉(zhuǎn)成 int64 傳遞到Java。

 

Java解碼字符串

 

Go 傳遞過來指針和長度,本質(zhì)對應(yīng)了一個 []byte,Java 需要將其解碼成字符串。

 

通過如下 utils 可以將 (address, length) 轉(zhuǎn)成 DirectByteBuffer,然后利用 CharsetDecoder 可以解碼到 CharBuffer 最后在轉(zhuǎn)成 String 。

 

通過這個方法,完全避免了 Go string 到 Java String 的多次深拷貝。

 

這里的 decode 動作肯定是省不了的,因為 Go string 本質(zhì)是 utf8 編碼的 []byte,而 Java String 本質(zhì)是 char[].

 

public class DirectMemoryUtils {

    private static final Unsafe unsafe;

 

    private static final Class<?> DIRECT_BYTE_BUFFER_CLASS;

    private static final long     DIRECT_BYTE_BUFFER_ADDRESS_OFFSET;

    private static final long     DIRECT_BYTE_BUFFER_CAPACITY_OFFSET;

    private static final long     DIRECT_BYTE_BUFFER_LIMIT_OFFSET;

 

    static {

        try {

            Field field = Unsafe.class.getDeclaredField("theUnsafe");

            field.setAccessible(true);

            unsafe = (Unsafe) field.get(null);

        } catch (Exception e) {

            throw new AssertionError(e);

        }

 

        try {

            ByteBuffer directBuffer = ByteBuffer.allocateDirect(0);

            Class<?> clazz = directBuffer.getClass();

            DIRECT_BYTE_BUFFER_ADDRESS_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("address"));

            DIRECT_BYTE_BUFFER_CAPACITY_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("capacity"));

            DIRECT_BYTE_BUFFER_LIMIT_OFFSET = unsafe.objectFieldOffset(Buffer.class.getDeclaredField("limit"));

            DIRECT_BYTE_BUFFER_CLASS = clazz;

        } catch (NoSuchFieldException e) {

            throw new RuntimeException(e);

        }

    }

 

    public static long allocateMemory(long size) {

        // 經(jīng)過測試 JNA 的 Native.malloc 吞吐量是 unsafe.allocateMemory 的接近2倍

        // return Native.malloc(size);

        return unsafe.allocateMemory(size);

    }

 

    public static void freeMemory(long address) {

        // Native.free(address);

        unsafe.freeMemory(address);

    }

 

    /**

     * @param address 用long表示一個來自C的指針, 指向一塊內(nèi)存區(qū)域

     * @param len     內(nèi)存區(qū)域長度

     * @return

     */

    public static ByteBuffer directBufferFor(long address, long len) {

        if (len > Integer.MAX_VALUE || len < 0L) {

            throw new IllegalArgumentException("invalid len " + len);

        }

        // 以下技巧來自O(shè)HC, 通過unsafe繞過構(gòu)造器直接創(chuàng)建對象, 然后對幾個內(nèi)部字段進行賦值

        try {

            ByteBuffer bb = (ByteBuffer) unsafe.allocateInstance(DIRECT_BYTE_BUFFER_CLASS);

            unsafe.putLong(bb, DIRECT_BYTE_BUFFER_ADDRESS_OFFSET, address);

            unsafe.putInt(bb, DIRECT_BYTE_BUFFER_CAPACITY_OFFSET, (int) len);

            unsafe.putInt(bb, DIRECT_BYTE_BUFFER_LIMIT_OFFSET, (int) len);

            return bb;

        } catch (Error e) {

            throw e;

        } catch (Throwable t) {

            throw new RuntimeException(t);

        }

    }

 

    public static byte[] readAll(ByteBuffer bb) {

        byte[] bs = new byte[bb.remaining()];

        bb.get(bs);

        return bs;

    }

}

 

6  左起右至優(yōu)化

 

先介紹 "左起右至切分": 使用3個參數(shù) (String leftDelim, int leftIndex, String rightDelim) 定位一個子字符,表示從給定的字符串左側(cè)數(shù)找到第 leftIndex 個 leftDelim 后,位置記錄為start,繼續(xù)往右尋找 rightDelim,位置記錄為end.則子字符串 [start+leftDelim.length(), end) 即為所求。

 

其中l(wèi)eftIndex從0開始計數(shù)。

 

例子:

字符串="a,b,c,d"

規(guī)則=("," , 1, ",")

結(jié)果="c"

 

第1個","右至","之間的內(nèi)容,計數(shù)值是從0開始的。

 

字符串="a=1 b=2 c=3"

規(guī)則=("b=", 0, " ")

結(jié)果="2"

 

第0個"b="右至" "之間的內(nèi)容,計數(shù)值是從0開始的。

 

在一個計算規(guī)則里會有很多 (leftDelim, leftIndex, rightDelim),但很多情況下 leftDelim 的值是相同的,可以復(fù)用。

 

優(yōu)化算法:

 

按 (leftDelim, leftIndex, rightDelim) 排序,假設(shè)排序結(jié)果存在 rules 數(shù)組里;

 

按該順序獲取子字符串;

 

處理 rules[i] 時,如果 rules[i].leftDelim == rules[i-1].leftDelim,那么 rules[i] 可以復(fù)用 rules[i-1] 緩存的start,根據(jù)排序規(guī)則知 rules[i].leftIndex>=rules[i-1].leftIndex,因此 rules[i] 可以少掉若干次 indexOf 。

 

7  動態(tài)GC優(yōu)化

 

基于 Go 版本 1.11.9

 

上線之后發(fā)現(xiàn)容易 OOM.進行了一些排查,有如下結(jié)論。

 

Go GC 的3個時機:

 

已用的堆內(nèi)存達到 NextGC 時;

 

連續(xù) 2min 沒有發(fā)生任何 GC;

 

用戶手動調(diào)用 runtime.GC() 或 debug.FreeOSMemory();

 

Go 有個參數(shù)叫 GOGC,默認是100。當每次GO GC完之后,會設(shè)置 NextGC = liveSize * (1 + GOGC/100)。

 

liveSize 是 GC 完之后的堆使用大小,一般由需要常駐內(nèi)存的對象組成。

 

一般常駐內(nèi)存是區(qū)域穩(wěn)定的,默認值 GOGC 會使得已用內(nèi)存達到 2 倍常駐內(nèi)存時才發(fā)生 GC。

 

但是 Go 的 GC 有如下問題:

 

根據(jù)公式,NextGC 可能會超過物理內(nèi)存;

 

Go 并沒有在內(nèi)存不足時進行 GC 的機制(而 Java 就可以);

 

于是,Go 在堆內(nèi)存不足(假設(shè)此時還沒達到 NextGC,因此不觸發(fā)GC)時唯一能做的就是向操作系統(tǒng)申請內(nèi)存,于是很有可能觸發(fā) OOM。

 

可以很容易構(gòu)造出一個程序,維持默認 GOGC = 100,我們保證常駐內(nèi)存>50%的物理內(nèi)存 (此時 NextGC 已經(jīng)超過物理機內(nèi)存了),然后以極快的速度不停堆上分配(比如一個for的無限循環(huán)),則這個 Go 程序必定觸發(fā) OOM (而 Java 則不會)。哪怕任何一刻時刻,其實我們強引用的對象占據(jù)的內(nèi)存始終沒有超過物理內(nèi)存。

 

另外,我們現(xiàn)在的內(nèi)存由 Go runtime 和 Java runtime (其實還有一些臨時的C空間的內(nèi)存)瓜分,而 Go runtime 顯然是無法感知 Java runtime 占用的內(nèi)存,每個 runtime 都認為自己能獨占整個物理內(nèi)存。實際在一臺 8G 的容器里,分1.5G給Java,Go 其實可用的 < 6G。

 

實現(xiàn)

 

定義:

 

低水位 = 0.6 * 總內(nèi)存

高水位 = 0.8 * 總內(nèi)存

 

抖動區(qū)間 = [低水位, 高水位] 盡量讓 常駐活躍內(nèi)存 * GOGC / 100 的值維持在這個區(qū)間內(nèi), 該區(qū)間大小要根據(jù)經(jīng)驗調(diào)整,才能盡量使得 GOGC 大但不至于 OOM。

 

活躍內(nèi)存=剛 GC 完后的 heapInUse

 

最小GOGC = 50,無論任何調(diào)整 GOGC 不能低于這個值

 

最大GOGC = 500 無論任何調(diào)整 GOGC 不能高于這個值

 

當 NextGC < 低水位時,調(diào)高 GOGC 幅度10;

 

當 NextGC > 高水位時,立即觸發(fā)一次 GC(由于是手動觸發(fā)的,根據(jù)文檔會有一些STW),然后公式返回計算出一個合理的 GOGC;

 

其他情況,維持 GOGC 不變;

 

這樣,如果常駐活躍內(nèi)存很小,那么 GOGC 會慢慢變大直到收斂某個值附近。如果常駐活躍內(nèi)存較大,那么 GOGC 會變小,盡快 GC,此時 GC 代價會提升,但總比 OOM 好吧!

 

這樣實現(xiàn)之后,機器占用的物理內(nèi)存水位會變高,這是符合預(yù)期的,只要不會 OOM, 我們就沒必要過早釋放內(nèi)存給OS(就像Java一樣)。

 

 

這臺機器在 09:44:39 附近發(fā)現(xiàn) NextGC 過高,于是趕緊進行一次 GC,并且調(diào)低 GOGC,否則如果該進程短期內(nèi)消耗大量內(nèi)存,很可能就會 OOM。

 

8  使用緊湊的數(shù)據(jù)結(jié)構(gòu)

 

由于業(yè)務(wù)變化,我們需要在內(nèi)存里緩存大量對象,約有1千萬個對象。

 

內(nèi)部結(jié)構(gòu)可以簡單理解為使用 map 結(jié)構(gòu)來存儲1千萬個 row 對象的指針。

 

type Row struct {

    Timestamp    int64

  StringArray  []string

    DataArray    []Data

    // 此處省略一些其他無用字段, 均已經(jīng)設(shè)為nil

}

 

type Data interface {

    // 省略一些方法

}

 

type Float64Data struct {

    Value float64

}

 

先不考慮map結(jié)構(gòu)的開銷,有如下估計:

 

Row數(shù)量 = 1千萬

 

字符串數(shù)組平均長度 = 10

 

字符串平均大小 = 12

 

Data 數(shù)組平均長度 = 4

估算占用內(nèi)存 = Row 數(shù)量*(int64 大小 + 字符串數(shù)組內(nèi)存 + Data 數(shù)組內(nèi)存) = 1千萬 * (8+10*12+4*8) = 1525MB。

 

再算上一些臨時對象,期望常駐內(nèi)存應(yīng)該比這個值多一些些,但實際上發(fā)現(xiàn)剛 GC 完常駐內(nèi)存還有4~6G,很容易OOM。

 

OOM的原因見上文的 "動態(tài)GC優(yōu)化"

 

進行了一些猜測和排查,最終驗證了原因是我們的算法沒有考慮語言本身的內(nèi)存代價以及大量無效字段浪費了較多內(nèi)存。

 

算一筆賬:

 

指針大小 = 8;

 

字符串占內(nèi)存 = sizeof(StringHeader) + 字符串長度;

 

數(shù)組占內(nèi)存 = sizeof(SliceHeader) + 數(shù)組cap * 數(shù)組元素占的內(nèi)存;

 

另外 Row 上有大量無用字段(均設(shè)置為 nil 或0)也要占內(nèi)存;

 

我們有1千萬的對象, 每個對象浪費8字節(jié)就浪費76MB。

 

 

這里忽略字段對齊等帶來的浪費。

 

浪費的點在:

 

數(shù)組 ca p可能比數(shù)組 len 長;

 

Row 上有大量無用字段, 即使賦值為 nil 也會占內(nèi)存(指針8字節(jié));

 

較多指針占了不少內(nèi)存;

 

 

 

最后,我們做了如下優(yōu)化:

 

確保相關(guān) slice 的 len 和 cap 都是剛剛好;

 

使用新的 Row 結(jié)構(gòu),去掉所有無用字段;

 

DataArray 數(shù)組的值使用結(jié)構(gòu)體而非指針;

 

9  字符串復(fù)用

 

根據(jù)業(yè)務(wù)特性,很可能產(chǎn)生大量值相同的字符串,但卻是不同實例。對此在局部利用字段 map[string]string 進行字符串復(fù)用,讀寫 map 會帶來性能損失,但可以有效減少內(nèi)存里重復(fù)的字符串實例,降低內(nèi)存/GC壓力。

為什么是局部? 因為如果是一個全局的 sync.Map 內(nèi)部有鎖, 損耗的代價會很大。

 

通過一個局部的map,已經(jīng)能顯著降低一個量級的string重復(fù)了,再繼續(xù)提升效果不明顯。

三  后續(xù)

 

這個 JVM inside 方案也被用于tair的數(shù)據(jù)采集方案,中心化 Agent 也是 Golang 寫的,但 tair 只提供了 Java SDK,因此也需要 Go call Java 方案。

 

SDK 里會發(fā)起阻塞型的 IO 請求,因此 worker 數(shù)量必須增加才能提高并發(fā)度。

 

此時 worker 不調(diào)用 runtime.LockOSThread() 獨占一個線程, 會由于陷入 CGO 調(diào)用時間太長導(dǎo)致Go 產(chǎn)生新線程, 輕則會導(dǎo)致性能下降, 重則導(dǎo)致 OOM。

 

四  總結(jié)

 

本文介紹了 Go 調(diào)用 Java 的一種實現(xiàn)方案,以及結(jié)合具體業(yè)務(wù)場景做的一系列性能優(yōu)化。

在實踐過程中,根據(jù)Go的特性設(shè)計合理的線程模型,根據(jù)線程模型使用ThreadLocal進行對象復(fù)用,還避免了各種鎖沖突。除了各種常規(guī)優(yōu)化之外,還用了一些unsafe編程進行優(yōu)化,unsafe其實本身并不可怕,只要充分了解其背后的原理,將unsafe在局部發(fā)揮最大功效就能帶來極大的性能優(yōu)化。

 

五 招聘

 

螞蟻智能監(jiān)控團隊負責解決螞蟻金服域內(nèi)外的基礎(chǔ)設(shè)施和業(yè)務(wù)應(yīng)用的監(jiān)控需求,正在努力建設(shè)一個支撐百萬級機器集群、億萬規(guī)模服務(wù)調(diào)用場景下的,覆蓋指標、日志、性能和鏈路等監(jiān)控數(shù)據(jù),囊括采集、清洗、計算、存儲乃至大盤展現(xiàn)、離線分析、告警覆蓋和根因定位等功能,同時具備智能化 AIOps 能力的一站式、一體化的監(jiān)控產(chǎn)品,并服務(wù)螞蟻主站、國際站、網(wǎng)商技術(shù)風險以及金融科技輸出等眾多業(yè)務(wù)和場景。如果你對這方面有興趣,歡迎加入我們。

試聽課
(責任編輯:代碼如詩)
------分隔線----------------------------
欄目列表
推薦內(nèi)容