小学学校网站建设情况资料,三水 网站建设,今天西安最新通告,石河子农八师建设兵团社保网站简介#xff1a; 一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。 作者 | 响风 来源 | 阿里技术公众号
一 背景
一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。
算子依赖了一些库:
Groovy aviatorscript
该应用有如…简介 一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。 作者 | 响风 来源 | 阿里技术公众号
一 背景
一个基于 Golang 编写的日志收集和清洗的应用需要支持一些基于 JVM 的算子。
算子依赖了一些库:
Groovy aviatorscript
该应用有如下特征:
1、处理数据量大
每分钟处理几百万行日志日志流速几十 MB/S每行日志可能需要执行多个计算任务计算任务个数不好估计几个到几千都有每个计算任务需要对一行日志进行切分/过滤一般条件10个
2、有一定实时性要求某些数据必须在特定时间内算完
3、4C8G 规格(后来扩展为 8C16G )内存比较紧张随着业务扩展需要缓存较多数据
简言之对性能要求很高。
有两种方案
Go call Java使用 Java 重写这个应用
出于时间紧张和代码复用的考虑选择了 Go call Java。
下文介绍了这个方案和一些优化经验。
二 Go call Java
根据 Java 进程与 Go 进程的关系可以再分为两种
方案1JVM inside: 使用 JNI 在当前进程创建出一个 JVMGo 和 JVM 运行在同一个进程里使用 CGO JNI 通信。 方案2JVM sidecar: 额外启动一个进程使用进程间通信机制进行通信。 方案1简单测试下性能调用 noop 方法 180万 OPS 其实也不是很快不过相比方案2好很多。
这是目前CGO固有的调用代价。 由于是noop方法 因此几乎不考虑传递参数的代价。方案2比较简单进程间通信方式是 UDS(Unix Domain Socket) based gRPC 但实际测了一下性能不好 调用 noop 方法极限5万的OPS并且随着传输数据变复杂伴随大量临时对象加剧 GC 压力。
不选择方案2还有一些考虑: 高性能的性能通信方式可以选择共享内存但共享内存也不能频繁申请和释放而是要长期复用; 一旦要长期使用就意味着要在一块内存空间上实现一个多进程的 mallocfree 算法; 使用共享内存也无法避免需要将对象复制进出共享内存的开销; 上述性能是在我的Mac机器上测出的但放到其他机器结果应该也差不多。 出于性能考虑选择了 JVM inside 方案。
1 JVM inside 原理
JVM inside CGO JNI. C 起到一个 Bridge 的作用。
2 CGO 简介
是 Go 内置的调用 C 的一种手段。详情见官方文档。
GO 调用 C 的另一个手段是通过 SWIG它为多种高级语言调用C/C提供了较为统一的接口但就其在Go语言上的实现也是通过CGO因此就 Go call C 而言使用 SWIG 不会获得更好的性能。详情见官网。以下是一个简单的例子Go 调用 C 的 printf(hello %s\n world)。 运行结果输出
hello world
在出入参不复杂的情况下CGO 是很简单的但要注意内存释放。
3 JNI 简介
JNI 可以用于 Java 与 C 之间的互相调用在大量涉及硬件和高性能的场景经常被用到。JNI 包含的 Java Invocation API 可以在当前进程创建一个 JVM。
以下只是简介JNI在本文中的使用JNI本身的介绍略过。下面是一个 C 启动并调用 Java 的String.format(hello %s %s %d world haha 2)并获取结果的例子。
#include stdio.h
#include stdlib.h
#include jni.hJavaVM *bootJvm() {JavaVM *jvm;JNIEnv *env;JavaVMInitArgs jvm_args;JavaVMOption options[4];// 此处可以定制一些JVM属性// 通过这种方式启动的JVM只能通过 -Djava.class.path 来指定classpath// 并且此处不支持*options[0].optionString -Djava.class.path -Dfoobar;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 versionjvm_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对我们已经没用了, 所以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 调用Java 执行 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;
}
依赖的头文件和动态链接库可以在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运行结果
java resulthello world haha 2
done
所有 env 关联的 ref会在 Detach 之后自动工释放但我们的最终方案里没有频繁 AttachDetach所以上述的代码保留手动 DeleteLocalRef 的调用。否则会引起内存泄漏(上面的代码相当于是持有强引用然后置为 null)。
实际中为了性能考虑还需要将各种 class/methodId 缓存住(转成 globalRef)避免每次都 Find。可以看到仅仅是一个简单的传参方法调用就如此繁杂更别说遇到复杂的嵌套结构了。这意味着我们使用 C 来做 Bridge这一层不宜太复杂。
实际实现的时候我们在 Java 侧处理了所有异常将异常信息包装成正常的 ResponseC 里不用检查 Java 异常简化了 C 的代码。
关于Java描述符
使用 JNI 时各种类名/方法签名字段签名等用的都是描述符名称在 Java 字节码文件中类/方法/字段的签名也都是使用这种格式。
除了通过 JDK 自带的 javap 命令可以获取完整签名外推荐一个 Jetbrain Intelli IDEA的插件 jclasslib Bytecode Viewer 可以方便的在IDE里查看类对应的字节码信息。 4 实现
我们目前只需要单向的 Go call Java并不需要 Java call Go。 代码比较繁杂这里就不放了就是上述2个简介的示例代码的结合体。考虑 Go 发起的一次 Java 调用要经历4步骤。
Go 通过 CGO 进入 C 环境C 通过 JNI 调用 JavaJava 处理并返回数据给 CC 返回数据给 Go三 性能优化
上述介绍了 Go call Java 的原理实现至此可以实现一个性能很差的版本。针对我们的使用场景分析性能差有几个原因:
单次调用有固定的性能损失调用次数越多损耗越大除了基本数据模型外的数据(主要是日志和计算规则)需要经历多次深复制才能抵达 Java数据量越大/调用次数越多损耗越大缺少合理的线程模型导致每次 Java 调用都需要 AttachDetach具有一定开销
以下是我们做的一些优化一些优化是针对我们场景的并不一定通用。
由于间隔时间有点久了 一些优化的量化指标已经丢失。1 预处理
将计算规则提前注册到 Java 并返回一个 id, 后续使用该 id 引用该计算规则, 减少传输的数据量。Java 可以对规则进行预处理, 可以提高性能:
Groovy 等脚本语言的静态化和预编译正则表达式预编译使用字符串池减少重复的字符串实例提前解析数据为特定数据结构
Groovy优化
为了进一步提高 Groovy 脚本的执行效率有以下优化:
预编译 Groovy 脚本为 Java class然后使用反射调用而不是使用 eval 尝试静态化 Groovy 脚本: 对 Groovy 不是很精通的人往往把它当 Java 来写因此很有可能写出的脚本可以被静态化利用 Groovy 自带的 org.codehaus.groovy.transform.sc.StaticCompileTransformation 可以将其静态化(不包含Groovy的动态特性)可以提升效率。自定义 Transformer 删除无用代码: 实际发现脚本里包含 打印日志/打印堆栈/打印到标准输出 等无用代码使用自定义 Transformer 移除相关字节码。
设计的时候考虑过 Groovy 沙箱用于防止恶意系统调用( System.exit(0) )和执行时间太长。出于性能和难度考虑现在没有启动沙箱功能。 动态沙箱是通过拦截所有方法调用(以及一些其他行为)实现的性能损失太大。 静态沙箱是通过静态分析在编译阶段发现恶意调用通过植入检测代码避免方法长时间不返回但由于 Groovy 的动态特性静态分析很难分析出 Groovy 的真正行为( 比如方法的返回类型总是 Object调用的方法本身是一个表达式只有运行时才知道 )因此有非常多的办法可以绕过静态分析调用恶意代码。2 批量化
减少 20%~30% CPU使用率。初期我们想通过接口加多实现的方式将代码里的 Splitter/Filter 等新增一个 Java 实现然后保持整体流程不变。
比如我们有一个 Filter
type Filter interface {Filter(string) bool
}
除了 Go 的实现外我们额外提供一个 Java 的实现它实现了调用 Java 的逻辑。
type JavaFilter struct {
}func (f *JavaFilter) Filter(content string) bool {// call java
}
但是这个粒度太细了流量高的应用每秒要处理80MB数据日志切分/字段过滤等需要调用非常多次类似 Filter 接口的方法。及时我们使用了 JVM inside 方案也无法减少单次调用 CGO 带来的开销。
另外在我们的场景下Go call Java 时要进行大量参数转换也会带来非常大的性能损失。
就该场景而言 如果使用 safe 编程每次调用必须对 content 字符串做若干次深拷贝才能传递到 Java。优化点:
将调用粒度做粗 避免多次调用 Java: 将整个清洗动作在 Java 里重新实现一遍 并且实现批量能力这样只需要调用一次 Java 就可以完成一组日志的多次清洗任务。
3 线程模型
考虑几个背景:
CGO 调用涉及 goroutine 栈扩容如果传递了一个栈上对象的指针(在我们的场景没有)可能会改变导致野指针当 Go 陷入 CGO 调用超过一段时间没有返回时Go 就会创建一个新线程应该是为了防止饿死其他 gouroutine 吧。
这个可以很简单的通过 C 里调用 sleep 来验证
C 调用 Java 之前当前线程必须已经调用过 AttachCurrentThread并且在适当的时候DetachCurrentThread。然后才能安全访问 JVM。频繁调用 AttachDetach 会有性能开销在 Java 里做的主要是一些 CPU 密集型的操作。
结合上述背景对 Go 调用 Java 做出了如下封装:实现一个 worker pool有n个worker(nCPU核数*2)。里面每个 worker 单独跑一个 goroutine使用 runtime.LockOSThread() 独占一个线程每个 worker 初始化后 立即调用 JNI 的 AttachCurrentThread 绑定当前线程到一个 Java 线程上这样后续就不用再调用了。至此我们将一个 goroutine 关联到了一个 Java 线程上。此后Go 需要调用 Java 时将请求扔到 worker pool 去竞争执行通过 chan 接收结果。
由于线程只有固定的几个Java 端可以使用大量 ThreadLocal 技巧来优化性能。 注意到有一个特殊的 Control Worker是用于发送一些控制命令的实践中发现当 Worker Queue 和 n 个 workers 都繁忙的时候控制命令无法尽快得到调用 导致根本停不下来。
控制命令主要是提前将计算规则注册(和注销)到 Java 环境从而避免每次调用 Java 时都传递一些额外参数。
关于 worker 数量
按理我们是一个 CPU 密集型动作应该 worker 数量与 CPU 相当即可但实际运行过程中会因为排队导致某些配置的等待时间比较长。我们更希望平均情况下每个配置的处理耗时增高但别出现某些配置耗时超高(毛刺)。于是故意将 worker 数量增加。
4 Java 使用 ThreadLocal 优化
复用 Decoder/CharBuffer 用于字符串解码复用计算过程中一些可复用的结构体避免 ArrayList 频繁扩容每个 Worker 预先在 C 里申请一块堆外内存用于存放每次调用的结果避免多次mallocfree。
当 ThreadLocal.get() obj.reset() new Obj() expand GC 时就能利用 ThreadLocal来加速。
obj.reset() 是重置对象的代价expand 是类似ArrayList等数据结构扩容的代价GC 是由于对象分配而引入的GC代价
大家可以使用JMH做一些测试在我的Mac机器上:
ThreadLocal.get() 5.847 ± 0.439 ns/opnew java.lang.Object() 4.136 ± 0.084 ns/op
一般情况下我们的 Obj 是一些复杂对象创建的代价肯定远超过 new java.lang.Object() 像 ArrayList 如果从零开始构建那么容易发生扩容不利于性能另外热点路径上创建大量对象也会增加 GC 压力。最终将这些代价均摊一下会发现合理使用 ThreadLocal 来复用对象性能会超过每次都创建新对象。
Log4j2的0 GC就用到了这些技巧。 由于这些Java线程是由JNI在Attach时创建的不受我们控制因此无法定制Thread的实现类否则可以使用类似Netty的FastThreadLocal再优化一把。5 unsafe编程
减少 10% CPU使用率。如果严格按照 safe 编程方式每一步骤都会遇到一些揪心的性能问题:
Go 调用 C: 请求体主要由字符串数组组成要拷贝大量字符串性能损失很大
大量 Go 风格的字符串要转成 C 风格的字符串此处有 malloc调用完之后记得 free 掉。Go 风格字符串如果包含 \0会导致 C 风格字符串提前结束。
C 调用 Java: C 风格的字符串无法直接传递给 Java需要经历一次解码或者作为 byte[] (需要一次拷贝)传递给 Java 去解码(这样控制力高一些我们需要考虑 UTF8 GBK 场景)。Java 处理并返回数据给 C: 结构体比较复杂C 很难表达比如二维数组/多层嵌套结构体/Map 结构转换代码繁杂易错。C 返回数据给 Go: 此处相当于是上述步骤的逆操作太浪费了。
多次实践时候针对上述4个步骤分别做了优化:
Go调用C: Go 通过 unsafe 拿到字符串底层指针地址和长度传递给 C全程只传递指针(转成 int64)避免大量数据拷贝。
我们需要保证字符串在堆上分配而非栈上分配才行Go 里一个简单的技巧是保证数据直接或间接跨goroutine引用就能保证分配到堆上。还可以参考 reflect.ValueOf() 里调用的 escape 方法。
Go的GC是非移动式GC因此即使GC了对象地址也不会变化
C调用Java: 这块没有优化因为结构体已经很简单了老老实实写Java处理并返回数据给C:
Java 解码字符串:Java 收到指针之后将指针转成 DirectByteBuffer 然后利用 CharsetDecoder 解码出 String。 Java返回数据给C: 考虑到返回的结构体比较复杂将其 Protobuf 序列化成 byte[] 然后传递回去 这样 C 只需要负责搬运几个数值。此处我们注意到有很多临时的 malloc结合我们的线程模型每个线程使用了一块 ThreadLocal 的堆外内存存放 Protobuf 序列化结果使用 writeTo(CodedOutputStream.newInstance(ByteBuffer))可以直接将序列化结果写入堆外 而不用再将 byte[] 拷贝一次。经过统计一般这块 Response 不会太大现在大小是 10MB超过这个大小就老老实实用 mallocfree了。
C返回数据给Go:Go 收到 C 返回的指针之后通过 unsafe 构造出 []byte然后调用 Protobuf 代码反序列化。之后如果该 []byte 不是基于 ThreadLocal 内存那么需要主动 free 掉它。
Golang中[]byte和string
代码中的 []byte(xxxStr) 和 string(xxxBytes) 其实都是深复制。
type SliceHeader struct {// 底层字节数组的地址Data uintptr// 长度Len int// 容量Cap int
}
type StringHeader struct {// 底层字节数组的地址Data uintptr// 长度Len int
}
Go 中的 []byte 和 string 其实是上述结构体的值利用这个事实可以做在2个类型之间以极低的代价做类型转换而不用做深复制。这个技巧在 Go 内部也经常被用到比如 string.Builder#String() 。
这个技巧最好只在方法的局部使用需要对用到的 []byte 和 string的生命周期有明确的了解。需要确保不会意外修改 []byte 的内容而导致对应的字符串发生变化。
另外将字面值字符串通过这种方式转成 []byte然后修改 []byte 会触发一个 panic。在 Go 向 Java 传递参数的时候我们利用了这个技巧将 Data(也就是底层的 void*指针地址)转成 int64 传递到Java。
Java解码字符串
Go 传递过来指针和长度本质对应了一个 []byteJava 需要将其解码成字符串。
通过如下 utils 可以将 (address length) 转成 DirectByteBuffer然后利用 CharsetDecoder 可以解码到 CharBuffer 最后在转成 String 。
通过这个方法完全避免了 Go string 到 Java String 的多次深拷贝。
这里的 decode 动作肯定是省不了的因为 Go string 本质是 utf8 编码的 []byte而 Java String 本质是 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) {// 经过测试 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的指针, 指向一块内存区域* param len 内存区域长度* return*/public static ByteBuffer directBufferFor(long address, long len) {if (len Integer.MAX_VALUE || len 0L) {throw new IllegalArgumentException(invalid len len);}// 以下技巧来自OHC, 通过unsafe绕过构造器直接创建对象, 然后对几个内部字段进行赋值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 左起右至优化
先介绍 左起右至切分: 使用3个参数 (String leftDelim int leftIndex String rightDelim) 定位一个子字符表示从给定的字符串左侧数找到第 leftIndex 个 leftDelim 后位置记录为start继续往右寻找 rightDelim位置记录为end.则子字符串 [startleftDelim.length() end) 即为所求。
其中leftIndex从0开始计数。例子: 字符串abcd 规则( 1 ) 结果c
第1个右至之间的内容计数值是从0开始的。字符串a1 b2 c3 规则(b 0 ) 结果2
第0个b右至 之间的内容计数值是从0开始的。在一个计算规则里会有很多 (leftDelim leftIndex rightDelim)但很多情况下 leftDelim 的值是相同的可以复用。
优化算法:
按 (leftDelim leftIndex rightDelim) 排序假设排序结果存在 rules 数组里按该顺序获取子字符串处理 rules[i] 时如果 rules[i].leftDelim rules[i-1].leftDelim那么 rules[i] 可以复用 rules[i-1] 缓存的start根据排序规则知 rules[i].leftIndexrules[i-1].leftIndex因此 rules[i] 可以少掉若干次 indexOf 。
7 动态GC优化
基于 Go 版本 1.11.9上线之后发现容易 OOM.进行了一些排查有如下结论。
Go GC 的3个时机
已用的堆内存达到 NextGC 时连续 2min 没有发生任何 GC用户手动调用 runtime.GC() 或 debug.FreeOSMemory()
Go 有个参数叫 GOGC默认是100。当每次GO GC完之后会设置 NextGC liveSize * (1 GOGC/100)
liveSize 是 GC 完之后的堆使用大小一般由需要常驻内存的对象组成。
一般常驻内存是区域稳定的默认值 GOGC 会使得已用内存达到 2 倍常驻内存时才发生 GC。
但是 Go 的 GC 有如下问题:
根据公式NextGC 可能会超过物理内存Go 并没有在内存不足时进行 GC 的机制(而 Java 就可以)
于是Go 在堆内存不足(假设此时还没达到 NextGC因此不触发GC)时唯一能做的就是向操作系统申请内存于是很有可能触发 OOM。
可以很容易构造出一个程序维持默认 GOGC 100我们保证常驻内存50%的物理内存 (此时 NextGC 已经超过物理机内存了)然后以极快的速度不停堆上分配(比如一个for的无限循环)则这个 Go 程序必定触发 OOM (而 Java 则不会)。哪怕任何一刻时刻其实我们强引用的对象占据的内存始终没有超过物理内存。
另外我们现在的内存由 Go runtime 和 Java runtime (其实还有一些临时的C空间的内存)瓜分而 Go runtime 显然是无法感知 Java runtime 占用的内存每个 runtime 都认为自己能独占整个物理内存。实际在一台 8G 的容器里分1.5G给JavaGo 其实可用的 6G。
实现
定义
低水位 0.6 * 总内存
高水位 0.8 * 总内存
抖动区间 [低水位 高水位] 尽量让 常驻活跃内存 * GOGC / 100 的值维持在这个区间内 该区间大小要根据经验调整才能尽量使得 GOGC 大但不至于 OOM。
活跃内存刚 GC 完后的 heapInUse
最小GOGC 50无论任何调整 GOGC 不能低于这个值
最大GOGC 500 无论任何调整 GOGC 不能高于这个值
当 NextGC 低水位时调高 GOGC 幅度10当 NextGC 高水位时立即触发一次 GC(由于是手动触发的根据文档会有一些STW)然后公式返回计算出一个合理的 GOGC其他情况维持 GOGC 不变
这样如果常驻活跃内存很小那么 GOGC 会慢慢变大直到收敛某个值附近。如果常驻活跃内存较大那么 GOGC 会变小尽快 GC此时 GC 代价会提升但总比 OOM 好吧
这样实现之后机器占用的物理内存水位会变高这是符合预期的只要不会 OOM 我们就没必要过早释放内存给OS(就像Java一样)。 这台机器在 09:44:39 附近发现 NextGC 过高于是赶紧进行一次 GC并且调低 GOGC否则如果该进程短期内消耗大量内存很可能就会 OOM。
8 使用紧凑的数据结构
由于业务变化我们需要在内存里缓存大量对象约有1千万个对象。
内部结构可以简单理解为使用 map 结构来存储1千万个 row 对象的指针。
type Row struct {Timestamp int64StringArray []stringDataArray []Data// 此处省略一些其他无用字段, 均已经设为nil
}type Data interface {// 省略一些方法
}type Float64Data struct {Value float64
}
先不考虑map结构的开销有如下估计:
Row数量 1千万字符串数组平均长度 10字符串平均大小 12Data 数组平均长度 4
估算占用内存 Row 数量(int64 大小 字符串数组内存 Data 数组内存) 1千万 (8101248) 1525MB。
再算上一些临时对象期望常驻内存应该比这个值多一些些但实际上发现刚 GC 完常驻内存还有4~6G很容易OOM。
OOM的原因见上文的 动态GC优化进行了一些猜测和排查最终验证了原因是我们的算法没有考虑语言本身的内存代价以及大量无效字段浪费了较多内存。
算一笔账
指针大小 8字符串占内存 sizeof(StringHeader) 字符串长度数组占内存 sizeof(SliceHeader) 数组cap * 数组元素占的内存另外 Row 上有大量无用字段(均设置为 nil 或0)也要占内存我们有1千万的对象 每个对象浪费8字节就浪费76MB。
这里忽略字段对齐等带来的浪费。浪费的点在:
数组 ca p可能比数组 len 长Row 上有大量无用字段 即使赋值为 nil 也会占内存(指针8字节)较多指针占了不少内存
最后我们做了如下优化:
确保相关 slice 的 len 和 cap 都是刚刚好使用新的 Row 结构去掉所有无用字段DataArray 数组的值使用结构体而非指针9 字符串复用
根据业务特性很可能产生大量值相同的字符串但却是不同实例。对此在局部利用字段 map[string]string 进行字符串复用读写 map 会带来性能损失但可以有效减少内存里重复的字符串实例降低内存/GC压力。
为什么是局部? 因为如果是一个全局的 sync.Map 内部有锁 损耗的代价会很大。
通过一个局部的map已经能显著降低一个量级的string重复了再继续提升效果不明显。
四 后续
这个 JVM inside 方案也被用于tair的数据采集方案中心化 Agent 也是 Golang 写的但 tair 只提供了 Java SDK因此也需要 Go call Java 方案。
SDK 里会发起阻塞型的 IO 请求因此 worker 数量必须增加才能提高并发度。此时 worker 不调用 runtime.LockOSThread() 独占一个线程 会由于陷入 CGO 调用时间太长导致Go 产生新线程 轻则会导致性能下降 重则导致 OOM。
五 总结
本文介绍了 Go 调用 Java 的一种实现方案以及结合具体业务场景做的一系列性能优化。
在实践过程中根据Go的特性设计合理的线程模型根据线程模型使用ThreadLocal进行对象复用还避免了各种锁冲突。除了各种常规优化之外还用了一些unsafe编程进行优化unsafe其实本身并不可怕只要充分了解其背后的原理将unsafe在局部发挥最大功效就能带来极大的性能优化。
原文链接
本文为阿里云原创内容未经允许不得转载。