背景
上了微服务的当,喜欢将服务各种拆分,公有云模式下服务器比较多,还能玩得转。到了私有化部署,有的客户连个技术人员都没有,只想一键启动就能用,于是将所有服务放在一台物理机上制作母盘,实施安装时省时省力,还能清公司的服务器库存。
但是问题来了,在一台物理机上部署几十个服务,有C++服务,有Java服务,还有中间件,内存非常吃紧。15G的内存,所有服务跑起来,啥也不干,10G就没了,更别提有些服务在运行过程中还会继续申请内存。于是提出资源占用优化。
首当其冲的是Java服务,top看一下,排在上面的是一众Java进程,内存杀手的名号不是白叫的。
堆内存调整
一说到调整内存,最容易想到的就是堆内存了,连-Xms -Xmx
这两个参数都不知道的Java程序员不是好curd boy。
- -Xms:初始堆内存大小,也就是Java进程一起动堆就占这么多物理内存,如果发现不够用就再申请内存(假如能申请的话,通常都喜欢将-Xms和-Xmx的值设置成一样的,因为据说动态扩展会影响性能?)。
- -Xmx:最大可分配堆内存大小,可以理解成虚拟内存。内存不够用又无法继续扩展时,就会OOM。
如果不需要在堆内存中聚集大量数据(比如:利用堆做缓存、在堆中排序并分页),大部分对象的生命周期都比较短的话,就不需要将堆内存设置的太大。
我个人的经验是,在程序刚启动时和压测后分别统计一次垃圾回收情况,垃圾回收统计使用如下命令:
jstat -gcutil pid
输出:
重点关注FGC(Full GC 次数)和GCT(GC总耗时),在OOM之前FGC会显著增大,另外,如果花了大量时间来回收垃圾,也能说明堆内存给太少了。
根据对每个服务负载的理解,进行了一波盲调,效果还不错。
内存还会继续上涨
明明通过-Xmx限制了堆内存大小,怎么压测完内存还是有明显上涨捏?我不能接受啊。大家都说是内存泄漏了,我不信!
为了搞清楚原因,我使用NMT追踪Java进程内存使用情况,NMT全称Native Memory Tracking,是HotSpot虚拟机的功能,可跟踪HotSpot虚拟机的内部内存使用情况。
需要在启动参数中加上-XX:NativeMemoryTracking=detail开启NMT,例如:
java -XX:NativeMemoryTracking=detail -jar -Xms96m -Xmx96m ./access-1.8.2.17.jar &
查看:
jcmd pid VM.native_memory summary scale=MB
输出:
解释:
- Reserved:reserved memory 是指JVM 通过mmaped PROT_NONE 申请的虚拟地址空间,在页表中已经存在了记录(entries)。
- Committed:committed memory 是JVM向操做系统实际分配的内存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,相当于程序实际申请的可用内存。committed申请的内存并不是说直接占用了物理内存,由于操作系统的内存管理是惰性的,对于已申请的内存虽然会分配地址空间,但并不会直接占用物理内存,真正使用的时候才会映射到实际的物理内存,所以committed >= res。
- Java Heap:堆内存,一般它的reserved等于-Xmx设置的值,committed等于-Xms设置的值。
- Class:加载的类与方法信息。其实就是 metaspace,包含两部分: 一是metadata,被-XX:MaxMetaspaceSize限制最大大小,另外是 classspace,被-XX:CompressedClassSpaceSize限制最大大小。
- Thread:线程占用的内存,每个线程栈占用大小受-Xss限制,默认是1MB左右,但是总大小没有限制。
- Code:JIT 即时编译后(C1 C2 编译器优化)的代码占用内存,受-XX:ReservedCodeCacheSize限制。
- GC:垃圾回收占用内存,例如垃圾回收需要的 CardTable,标记数,区域划分记录,还有标记 GC Root等等,都需要内存。这个不受限制,一般不会很大的。Parallel GC 不会占什么内存,G1 最多会占堆内存 10%左右额外内存,ZGC 会最多会占堆内存 15~20%左右额外内存,但是这些都在不断优化。(注意,不是占用堆的内存,而是大小和堆内存里面对象占用情况相关)。
- Internal:命令行解析,JVMTI 使用的内存,这个不受限制,一般不会很大的。
- Symbol:常量池占用的大小,字符串常量池受-XX:StringTableSize个数限制,总内存大小不受限制。
- Native Memory Tracking:内存采集本身占用的内存大小,如果没有打开采集(那就看不到这个了)。
在程序启动时查看一次,运行一段时间后再查看一次,对比之后就知道是哪块内存在增长了,然后有针对性的去优化。
这里可操作空间比较大的就是Java Heap和Thread占用的内存了,其他内存区域一般不会占用很大的空间,也不建议去调整。在我的项目里,所有Java进程的Thread总共占了八百多MB的内存,有点哈人,所以优化方向已经很明确了,那就是减少线程数量。
减少线程数量
要减少线程数量,首先要搞明白这些线程都是由谁创建的,用在哪里。使用:
jstack -l pid > stack.txt
导出线程快照,可以从线程名或线程栈中的方法名大概猜出线程的作用。
下面给出一些常见技术栈的线程数调整方式,仅供参考,线程数应该调整到多少,以自己的实际情况为准。
Tomcat
tomcat工作线程(线程名一般是http-nio-port-exec-n这种形式)数:
server:
tomcat:
min-spare-threads: 1
max-threads: 8
非web项目禁用web功能,可以不创建web线程:
spring.main.web-application-type: none
Dubbo
DubboServerHandler线程数:
<dubbo:protocol name="dubbo" dispatcher="all" threadpool="fixed" threads="32" />
Logback
我发现每个项目都有名为logback-1至logback-8的这8个线程,本来想试着调整一下,结果发现这玩意居然是代码里写死的。
在ch.qos.logback.core.util.ExecutorServiceUtil这个类中,有个方法:
static public ScheduledExecutorService newScheduledExecutorService() {
return new ScheduledThreadPoolExecutor(CoreConstants.SCHEDULED_EXECUTOR_POOL_SIZE, THREAD_FACTORY);
}
再看CoreConstants.SCHEDULED_EXECUTOR_POOL_SIZE
:
package ch.qos.logback.core;
public class CoreConstants {
// Apparently ScheduledThreadPoolExecutor has limitation where a task cannot be submitted from
// within a running task unless the pool has worker threads already available. ThreadPoolExecutor
// does not have this limitation.
// This causes tests failures in SocketReceiverTest.testDispatchEventForEnabledLevel and
// ServerSocketReceiverFunctionalTest.testLogEventFromClient.
// We thus set a pool size > 0 for tests to pass.
public static final int SCHEDULED_EXECUTOR_POOL_SIZE = 8;
}
上面那段注释翻译一下:
显然,ScheduledThreadPoolExecutor有一个限制,即除非池中已有可用的工作线程,否则无法从正在运行的任务中提交任务。ThreadPoolExecutor没有这个限制。
这会导致SocketReceiverTest.testDispatchEventForEnabledLevel和ServerSocketReceiver FunctionalTest.testLogEventFromClient测试失败。
因此,我们将线程池大小设置为>0,以便测试通过。
好家伙,搞了半天这8个线程是为了让单元测试通过。我使用的logback版本是1.2.3,不知道高版本的logback会不会解决这个问题。
野线程
我发现每个项目里都有一些类似下面的线程:
"pool-3-thread-4" #27 prio=5 os_prio=0 tid=0x00007f9e08042000 nid=0x73a0 waiting on condition [0x00007f9e657d2000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000f9cc0470> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
Locked ownable synchronizers:
- None
它们的名称一般叫pool-n-thread-m,从它们的名称和栈里的方法名无法看出由谁创建,用在何处。从线程名的命名风格着手,我在java.util.concurrent.Executors.DefaultThreadFactory中找到了相关代码实现:
static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
}
在这个构造器里打个断点,然后debug,就能找到是哪里调用它了。