背景

上了微服务的当,喜欢将服务各种拆分,公有云模式下服务器比较多,还能玩得转。到了私有化部署,有的客户连个技术人员都没有,只想一键启动就能用,于是将所有服务放在一台物理机上制作母盘,实施安装时省时省力,还能清公司的服务器库存。

但是问题来了,在一台物理机上部署几十个服务,有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,就能找到是哪里调用它了。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐