JVM 调优 (4) -- 调优实践

1. 简介

什么是调优?

  1. 根据需求进行JVM规划和预调优
  2. 优化运行JVM运行环境(慢,卡顿)
  3. 解决JVM运行过程中出现的各种问题(OOM)

对于调优这个事情来说,一般就是三个过程:

  1. 性能监控:问题没有发生,你并不知道你需要调优什么。此时需要一些系统 、应用的监控工具来发现问题
  2. 性能分析:问题已经发生,但是你并不知道问题到底出在哪里。此时就需要使用工具 、经验对系统 、应用进行瓶颈分析,以求定位到问题原因。
  3. 性能调优:经过上一步的分析定位到了问题所在,需要对问题进行解决,使用代码 、配置等手段进行优化。

1. 调优前的准备

调优是需要做好准备工作的,毕竟每一个应用的业务目标都不尽相同,性能瓶颈也不会总在同一个点上。在业务应用层面,我们需要:

  1. 需要了解系统的总体架构,明确压力方向。比如系统的哪一个接口 、模块是使用率最高的,面临高并发的挑战
  2. 需要构建测试环境来测试应用的性能,使用 ab 、loadrunner 、jmeter 都可以。
  3. 对关键业务数据量进行分析,这里主要指的是对一些数据的量化分析,如数据库一天的数据量有多少;缓存的数据量有多大等
  4. 了解系统的响应速度 、吞吐量 、TPS 、QPS 等指标需求,比如秒杀系统对响应速度和 QPS 的要求是非常高的
  5. 了解系统相关软件的版本 、模式和参数等,有时候限于应用依赖服务的版本、模式等,性能也会受到一定的影响。
  1. 吞吐量:用户代码时间 /(用户代码执行时间 + 垃圾回收时间)
  2. 响应时间:STW越短,响应时间越好
    .

所谓调优,首先确定,追求啥?吞吐量优先,还是响应时间优先?还是在满足一定的响应时间的情况下,要求达到多大的吞吐量

  1. 吞吐量优先的一般选择:PS + PO
  2. 响应时间优先:网站 GUI API (JDK 1.8 建议使用 G1)

调优步骤:

  1. 熟悉业务场景(没有最好的垃圾回收器,只有最合适的垃圾回收器)
    1. 响应时间、停顿时间 [CMS G1 ZGC] (需要给用户作响应)
    2. 吞吐量 = 用户时间 /( 用户时间 + GC时间) [PS]
  2. 选择回收器组合
  3. 计算内存需求(经验值 1.5G 16G)
  4. 选定CPU(越高越好)
  5. 设定年代大小、升级年龄
  6. 设定日志参数
    1. -Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause
    2. 或者每天产生一个日志文件
  7. 观察日志情况

在这里插入图片描述

2. 优化环境

  1. 有一个50万PV的资料类网站(从磁盘提取文档到内存)原服务器32位,1.5G 的堆,用户反馈网站比较缓慢,因此公司决定升级,新的服务器为64位,16G 的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了
    1. 为什么原网站慢?
      很多用户浏览数据,很多数据load到内存,内存不足,频繁GC,STW长,响应时间变慢
    2. 为什么会更卡顿?
      内存越大,FGC时间越长
    3. 咋办?
      PS -> PN + CMS 或者 G1
  2. 系统CPU经常100%,如何调优?(面试高频)
    CPU100%那么一定有线程在占用系统资源,
    1. 找出哪个进程cpu高(top)
    2. 该进程中的哪个线程cpu高(top -Hp)
    3. 导出该线程的堆栈 (jstack)
    4. 查找哪个方法(栈帧)消耗时间 (jstack)
    5. 工作线程占比高 | 垃圾回收线程占比高
  3. 系统内存飙高,如何查找问题?(面试高频)
    1. 导出堆内存 (jmap)
    2. 分析 (jhat jvisualvm mat jprofiler … )
  4. 如何监控JVM
    1. jstat jvisualvm jprofiler arthas top…

2. 内存溢出 – OOM

1. Java heap space

1. 内存泄漏引起堆内存溢出

Java 的自动内存管理依赖 GC, GC 会一遍又一遍地扫描内存区域, 将不使用的对象删除. 简单来说, Java 中的内存泄漏, 就是那些逻辑上不再使用的对象, 却没有被 垃圾收集程序 给干掉. 从而导致垃圾对象继续占用堆内存中, 逐渐堆积, 最后造成java.lang.OutOfMemoryError: Java heap space错误。

案例:

import java.util.*;

public class KeylessEntry {

    static class Key {
        Integer id;

        Key(Integer id) {
        this.id = id;
        }

        @Override
        public int hashCode() {
        return id.hashCode();
        }
     }

    public static void main(String[] args) {
        Map m = new HashMap();
        while (true){
        for (int i = 0; i < 10000; i++){
           if (!m.containsKey(new Key(i))){
               m.put(new Key(i), "Number:" + i);
           }
        }
        System.out.println("m.size()=" + m.size());
        }
    }
}

粗略一看, 可能觉得没什么问题, 因为这最多缓存 10000 个元素嘛! 但仔细审查就会发现, Key 这个类只重写了 hashCode()方法, 却没有重写equals()方法, 于是就会一直往 HashMap 中添加更多的 Key。

Key 类没有重写 equals 方法,则默认使用 Object 类中的 equals 方法判断对象是否相同,Object 类中的 equals 判断对象是否相同的依据是对象的地址值是否相同,故每一次 new 出来的 Key 实例都是不相同的对象实例。

随着时间推移, “cached” 的对象会越来越多. 当泄漏的对象占满了所有的堆内存, GC 又清理不了, 就会抛出 java.lang.OutOfMemoryError:Java heap space错误。

解决办法很简单, 在 Key 类中恰当地实现 equals() 方法即可:

@Override
public boolean equals(Object o) {
    boolean response = false;
    if (o instanceof Key) {
       response = (((Key)o).id).equals(this.id);
    }
    return response;
}
2. 一个 SpringMVC 中的场景

为了轻易地兼容从 Struts2 迁移到 SpringMVC 的代码, 在 Controller 中直接获取 request.所以在 ControllerBase 类中通过 ThreadLocal 缓存了当前线程所持有的 request 对象:

public abstract class ControllerBase {

    private static ThreadLocal<HttpServletRequest> requestThreadLocal = new ThreadLocal<HttpServletRequest>();

    public static HttpServletRequest getRequest(){
        return requestThreadLocal.get();
    }
    public static void setRequest(HttpServletRequest request){
        if(null == request){
        requestThreadLocal.remove(); //这一步可以避免内存泄漏
        return;
        }
        requestThreadLocal.set(request);
    }
}

然后在 SpringMVC 的拦截器(Interceptor)实现类中, 在 preHandle 方法里, 将 request 对象保存到 ThreadLocal 中:

/**
 * 登录拦截器
 */
public class LoginCheckInterceptor implements HandlerInterceptor {
    private List<String> excludeList = new ArrayList<String>();
    public void setExcludeList(List<String> excludeList) {
        this.excludeList = excludeList;
    }

    private boolean validURI(HttpServletRequest request){
        // 如果在排除列表中
        String uri = request.getRequestURI();
        Iterator<String> iterator = excludeList.iterator();
        while (iterator.hasNext()) {
        String exURI = iterator.next();
        if(null != exURI && uri.contains(exURI)){
            return true;
        }
        }
        // 可以进行登录和权限之类的判断
        LoginUser user = ControllerBase.getLoginUser(request);
        if(null != user){
        return true;
        }
        // 未登录,不允许
        return false;
    }

    private void initRequestThreadLocal(HttpServletRequest request){
        ControllerBase.setRequest(request);
        request.setAttribute("basePath", ControllerBase.basePathLessSlash(request));
    }
    private void removeRequestThreadLocal(){
        ControllerBase.setRequest(null);
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler) throws Exception {
        initRequestThreadLocal(request);
        // 如果不允许操作,则返回false即可
        if (false == validURI(request)) {
        // 此处抛出异常,允许进行异常统一处理
        throw new NeedLoginException();
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler, ModelAndView modelAndView)
        throws Exception {
        removeRequestThreadLocal();
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
        HttpServletResponse response, Object handler, Exception ex)
        throws Exception {
        removeRequestThreadLocal();
    }
}

在 postHandle 和 afterCompletion 方法中, 清理 ThreadLocal 中的 request 对象。

但在实际使用过程中, 业务开发人员将一个很大的对象(如占用内存 200MB 左右的 List)设置为 request 的 Attributes, 传递到 JSP 中。

JSP 代码中可能发生了异常, 则 SpringMVC 的 postHandle 和 afterCompletion 方法将不会被执行。

Tomcat 中的线程调度, 可能会一直调度不到那个抛出了异常的线程, 于是 ThreadLocal 一直 hold 住 request。 随着运行时间的推移,把可用内存占满, 一直在执行 Full GC, 系统直接卡死。

后续的修正:通过 Filter, 在 finally 语句块中清理 ThreadLocal。

@WebFilter(value="/*", asyncSupported=true)
public class ClearRequestCacheFilter implements Filter{

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
    FilterChain chain) throws IOException, ServletException {
        clearControllerBaseThreadLocal();
        try {
            chain.doFilter(request, response);
        } finally {
            clearControllerBaseThreadLocal();
        }
    }

    private void clearControllerBaseThreadLocal() {
        ControllerBase.setRequest(null);
    }
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}
    @Override
    public void destroy() {}
}

教训是:可以使用 ThreadLocal, 但必须有受控制的释放措施、一般就是 try-finally 的代码形式。

  1. 说明: SpringMVC 的 Controller 中, 其实可以通过 @Autowired 注入 request, 实际注入的是一个 HttpServletRequestWrapper 对象, 执行时也是通过 ThreadLocal 机制调用当前的 request。
  2. 常规方式: 直接在 Controller 方法中接收 request 参数即可。
3. 解决方案

如果设置的最大内存不满足程序的正常运行, 只需要增大堆内存即可,但很多情况下, 增加堆内存空间并不能解决问题。比如存在内存泄漏, 增加堆内存只会推迟 java.lang.OutOfMemoryError: Java heap space 错误的触发时间。

当然, 增大堆内存, 可能会增加 GC pauses 的时间, 从而影响程序的 吞吐量或延迟。要从根本上解决问题, 则需要排查分配内存的代码. 简单来说, 需要解决这些问题:

  1. 哪类对象占用了最多内存?
  2. 这些对象是在哪部分代码中分配的?

要搞清这一点, 可能需要好几天时间。下面是大致的流程:
3. 获得在生产服务器上执行堆转储(heap dump)的权限。“转储”(Dump)是堆内存的快照, 稍后可以用于内存分析. 这些快照中可能含有机密信息, 例如密码、信用卡账号等, 所以有时候, 由于企业的安全限制, 要获得生产环境的堆转储并不容易。

  1. 在适当的时间执行堆转储。一般来说,内存分析需要比对多个堆转储文件, 假如获取的时机不对, 那就可能是一个“废”的快照. 另外, 每次执行堆转储, 都会对JVM进行“冻结”, 所以生产环境中,也不能执行太多的Dump操作,否则系统缓慢或者卡死,你的麻烦就大了。

  2. 用另一台机器来加载Dump文件。一般来说, 如果出问题的JVM内存是8GB, 那么分析 Heap Dump 的机器内存需要大于 8GB. 打开转储分析软件。如 VisualVM。

  3. 检测快照中占用内存最大的 GC roots。 这对新手来说可能有点困难, 但这也会加深你对堆内存结构以及 navigation 机制的理解。

  4. 接下来, 找出可能会分配大量对象的代码. 如果对整个系统非常熟悉, 可能很快就能定位了。

2. GC overhead limit exceeded

1. 概述

java.lang.OutOfMemoryError: GC overhead limit exceeded 这种情况发生的原因是, 程序基本上耗尽了所有的可用内存, GC 也清理不了。

注意,java.lang.OutOfMemoryError: GC overhead limit exceeded 错误只在连续多次 GC 都只回收了不到 2% 的极端情况下才会抛出。假如不抛出 GC overhead limit 错误会发生什么情况呢? 那就是 GC 清理的这么点内存很快会再次填满, 迫使 GC 再次执行. 这样就形成恶性循环, CPU 使用率一直是 100%, 而 GC 却没有任何成果. 系统用户就会看到系统卡死 - 以前只需要几毫秒的操作, 现在需要好几分钟才能完成.
在这里插入图片描述

2. 案例

以下代码在无限循环中往 Map 里添加数据。 这会导致GC overhead limit exceeded错误:

import java.util.Map;
import java.util.Random;
public class TestWrapper {
    public static void main(String args[]) throws Exception {
        Map map = System.getProperties();
        Random r = new Random();
        while (true) {
            map.put(r.nextInt(), "value");
        }
    }
}

配置JVM参数: -Xmx12m。执行时产生的错误信息如下所示:
在这里插入图片描述
很快就看到了 java.lang.OutOfMemoryError: GC overhead limit exceeded 错误提示消息。但实际上这个示例是有些坑的. 因为配置不同的堆内存大小, 选用不同的 GC 算法, 产生的错误信息也不相同。例如,当 Java 堆内存设置为 10M 时:
在这里插入图片描述
读者应该试着修改参数, 执行看看具体。错误提示以及堆栈信息可能不太一样。

这里在 Map 进行 rehash 时抛出了 java.lang.OutOfMemoryError: Java heap space 错误消息. 如果使用其他 垃圾收集算法, 比如 -XX:+UseConcMarkSweepGC, 或者 -XX:+UseG1GC, 错误将被默认的 exception handler 所捕获, 但是没有 stacktrace 信息, 因为在创建 Exception 时 没办法填充 stacktrace 信息。

如:
在这里插入图片描述
建议读者修改内存配置, 以及垃圾收集算法进行测试。

这些真实的案例表明, 在资源受限的情况下, 无法准确预测程序会死于哪种具体的原因。所以在这类错误面前, 不能绑死某种特定的错误处理顺序。

3. 解决方案

有一种应付了事的解决方案, 就是不想抛出 java.lang.OutOfMemoryError: GC overhead limit exceeded错误信息, 则添加下面启动参数:

// 不推荐
-XX:-UseGCOverheadLimit

强烈建议不要指定该选项: 因为这不能真正地解决问题,只能推迟一点 out of memory 错误发生的时间,到最后还得进行其他处理。指定这个选项, 会将原来的 java.lang.OutOfMemoryError: GC overhead limit exceeded 错误掩盖,变成更常见的 java.lang.OutOfMemoryError: Java heap space 错误消息。

有时候触发 GC overhead limit 错误的原因, 是因为分配给 JVM 的堆内存不足。这种情况下只需要增加堆内存大小即可。

3. Permgen space

在JDK1.7及之前的版本, 永久代(permanent generation) 主要用于存储加载/缓存到内存中的 class 定义, 包括 class 的 名称(name), 字段(fields), 方法(methods)和字节码(method bytecode); 以及常量池(constant pool information); 对象数组(object arrays)/类型数组(type arrays)所关联的 class, 还有 JIT 编译器优化后的class信息等。

PermGen 的使用量和JVM加载到内存中的 class 数量/大小有关。可以说 java.lang.OutOfMemoryError: PermGen space 的主要原因, 是加载到内存中的 class 数量太多或体积太大。

4. Metaspace

1. 概述

JVM限制了Java程序的最大内存, 修改/指定启动参数可以改变这种限制。Java将堆内存划分为多个部分, 如下图所示:
在这里插入图片描述
java.lang.OutOfMemoryError: Metaspace 错误所表达的信息是: 元数据区(Metaspace) 已被用满。

从Java 8开始,内存结构发生重大改变, 不再使用Permgen, 而是引入一个新的空间: Metaspace. 这种改变基于多方面的考虑, 部分原因列举如下:

  1. Permgen空间的具体多大很难预测。指定小了会造成 java.lang.OutOfMemoryError: Permgen size 错误, 设置多了又造成浪费。

  2. 为了 GC 性能 的提升, 使得垃圾收集过程中的并发阶段不再 停顿, 另外对 metadata 进行特定的遍历(specific iterators)。

  3. 对 G1垃圾收集器 的并发 class unloading 进行深度优化。

Metaspace 的使用量与 JVM 加载到内存中的 class 数量/大小有关。可以说, java.lang.OutOfMemoryError: Metaspace 错误的主要原因, 是加载到内存中的 class 数量太多或者体积太大。

2. 案例
public class Metaspace {
  static javassist.ClassPool cp = javassist.ClassPool.getDefault();

  public static void main(String[] args) throws Exception{
    for (int i = 0; ; i++) { 
      Class c = cp.makeClass("eu.plumbr.demo.Generated" + i).toClass();
    }
  }
}

可以看到, 使用 javassist 工具库生成 class 那是非常简单。在 for 循环中, 动态生成很多class, 最终将这些class加载到 Metaspace 中。

执行这段代码, 随着生成的class越来越多, 最后将会占满 Metaspace 空间, 抛出 java.lang.OutOfMemoryError: Metaspace. 在Mac OS X上, Java 1.8.0_05 环境下, 如果设置了启动参数 -XX:MaxMetaspaceSize=64m, 大约加载 70000 个class后JVM就会挂掉。

3. 解决方案

如果抛出与 Metaspace 有关的 OutOfMemoryError , 第一解决方案是增加 Metaspace 的大小. 使用下面这样的启动参数:

-XX:MaxMetaspaceSize=512m

这里将 Metaspace 的最大值设置为 512MB, 如果没有用完, 就不会抛出 OutOfMemoryError。

有一种看起来很简单的方案, 是直接去掉 Metaspace 的大小限制。 但需要注意, 不限制 Metaspace 内存的大小, 假若物理内存不足, 有可能会引起内存交换(swapping), 严重拖累系统性能。 此外,还可能造成 native 内存分配失败等问题。

在现代应用集群中,宁可让应用节点挂掉, 也不希望其响应缓慢。

如果不想收到报警, 可以像鸵鸟一样, 把 java.lang.OutOfMemoryError: Metaspace 错误信息隐藏起来。 但这不能真正解决问题, 只会推迟问题爆发的时间。 如果确实存在内存泄露, 按之前的做法处理即可。

5. Unable to create new native thread

1. 概述

JVM 中的线程需要内存空间来执行自己的任务. 如果线程数量太多, 就会引入新的问题。java.lang.OutOfMemoryError: Unable to create new native thread 错误表示: 程序创建的线程数量已达到上限值
在这里插入图片描述
JVM 向操作系统申请创建新的 native thread(原生线程)时, 就有可能会碰到 java.lang.OutOfMemoryError: Unable to create new native thread 错误. 如果底层操作系统创建新的 native thread 失败, JVM 就会抛出相应的OutOfMemoryError. 原生线程的数量受到具体环境的限制, 通过一些测试用例可以找出这些限制, 请参考下文的示例. 但总体来说, 导致 java.lang.OutOfMemoryError: Unable to create new native thread 错误的场景大多经历以下这些阶段:

  1. Java 程序向JVM请求创建一个新的Java线程;

  2. JVM本地代码(native code)代理该请求, 尝试创建一个操作系统级别的 native thread(原生线程);

  3. 操作系统尝试创建一个新的native thread, 需要同时分配一些内存给该线程;

  4. 如果操作系统的虚拟内存已耗尽, 或者是受到32位进程的地址空间限制(约2-4GB), OS就会拒绝本地内存分配;

  5. JVM 抛出 java.lang.OutOfMemoryError: Unable to create new native thread 错误

2. 案例

下面的代码在一个死循环中创建并启动很多新线程。代码执行后, 很快就会达到操作系统的限制, 报出 java.lang.OutOfMemoryError: Unable to create new native thread 错误:

 while(true){
    new Thread(new Runnable(){
        public void run() {
            try {
                Thread.sleep(10000000);
            } catch(InterruptedException e) { }        
        }    
    }).start();
} 

原生线程的数量由具体环境决定, 比如, 在 Windows, Linux 和 Mac OS X 系统上:

  1. 64-bit Mac OS X 10.9, Java 1.7.0_45 – JVM 在创建 #2031 号线程之后挂掉
  2. 64-bit Ubuntu Linux, Java 1.7.0_45 – JVM 在创建 #31893 号线程之后挂掉
  3. 64-bit Windows 7, Java 1.7.0_45 – 由于操作系统使用了不一样的线程模型, 这个错误信息似乎不会出现. 创建 #250,000 号线程之后,Java进程依然存在, 但虚拟内存(swap file) 的使用量达到了 10GB, 系统运行极其缓慢,基本上没法运行了。

所以如果想知道系统的极限在哪儿, 只需要一个小小的测试用例就够了, 找到触发 java.lang.OutOfMemoryError: Unable to create new native thread 时创建的线程数量即可。

3. 解决方案

有时可以修改系统限制来避开 Unable to create new native thread 问题. 假如JVM受到用户空间(user space)文件数量的限制, 像下面这样,就应该想办法增大这个值:

[root@dev ~]# ulimit -a
core file size          (blocks, -c) 0
...... 省略部分内容 ......
max user processes      (-u) 1800

更多的情况, 触发创建 native 线程时的OutOfMemoryError, 表明编程存在BUG. 比如, 程序创建了成千上万的线程, 很可能就是某些地方出大问题了 —— 没有几个程序可以 Hold 住上万个线程的。

一种解决办法是执行线程转储(thread dump) 来分析具体情况。 一般需要花费好几个工作日来处理。

6. Out of swap space

1. 概述

JVM启动参数指定了最大内存限制。如 -Xmx 以及相关的其他启动参数. 假若JVM使用的内存总量超过可用的物理内存, 操作系统就会用到虚拟内存。错误信息 java.lang.OutOfMemoryError: Out of swap space? 表明, 交换空间(swap space,虚拟内存) 不足,是由于物理内存和交换空间都不足所以导致内存分配失败。
在这里插入图片描述
如果 native heap 内存耗尽, 内存分配时, JVM 就会抛出 java.lang.OutOfmemoryError: Out of swap space? 错误消息, 这个消息告诉用户, 请求分配内存的操作失败了。

Java进程使用了虚拟内存才会发生这个错误。 对 Java的垃圾收集 来说这是很难应付的场景。即使现代的 GC算法 很先进, 但虚拟内存交换引发的系统延迟, 会让 GC暂停时间 膨胀到令人难以容忍的地步。

通常是操作系统层面的原因导致 java.lang.OutOfMemoryError: Out of swap space? 问题, 例如:

  1. 操作系统的交换空间太小。
  2. 机器上的某个进程耗光了所有的内存资源。

当然也可能是应用程序的本地内存泄漏(native leak)引起的, 例如, 某个程序/库不断地申请本地内存,却不进行释放。

2. 解决方案

这个问题有多种解决办法。

第一种, 也是最简单的方法, 增加虚拟内存(swap space) 的大小. 各操作系统的设置方法不太一样, 比如Linux,可以使用下面的命令设置:

swapoff -a
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile

其中创建了一个大小为 640MB 的 swapfile(交换文件) 并启用该文件。

因为垃圾收集器需要清理整个内存空间, 所以虚拟内存对 Java GC 来说是难以忍受的。存在内存交换时, 执行 垃圾收集 的 暂停时间 会增加上百倍,甚至更多, 所以最好不要增加虚拟内存。

如果程序允许环境还受到 “坏邻居效应” 的干扰, 那么JVM还要和其他程序竞争计算资源, 提高性能的办法就是单独部署到专用的服务器/虚拟机中。

大多数时候, 我们唯一能做的就是升级服务器配置, 增加物理机的内存。当然也可以进行程序优化, 降低内存空间的使用量, 通过堆转储分析器可以检测到哪些方法/代码分配了大量的内存。

7. Requested array size exceeds VM limit

1. 概述

Java平台限制了数组的最大长度。各个版本的具体限制可能稍有不同, 但范围都在 1 ~ 21亿 之间。如果程序抛出 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 错误, 就说明想要创建的数组长度超过限制。
在这里插入图片描述
这个错误是由JVM中的本地代码抛出的. 在真正为数组分配内存之前, JVM会执行一项检查: 要分配的数据结构在该平台是否可以寻址(addressable). 当然, 这个错误比你所想的还要少见得多。

一般很少看到这个错误, 因为Java使用 int 类型作为数组的下标(index, 索引)。在Java中, int类型的最大值为 2^31 – 1 = 2,147,483,647。大多数平台的限制都约等于这个值 —— 例如在 64位的 MB Pro 上, Java 1.7 平台可以分配长度为 2,147,483,645, 以及 Integer.MAX_VALUE-2) 的数组。

再增加一点点长度, 变成 Integer.MAX_VALUE-1 时, 就会抛出我们所熟知的Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit

在有的平台上, 这个最大限制可能还会更小一些, 例如在32位Linux, OpenJDK 6 上面, 数组长度大约在 11亿左右(约2^30) 就会抛出 “java.lang.OutOfMemoryError: Requested array size exceeds VM limit“ 错误。要找出具体的限制值, 可以执行一个小小的测试用例.

2. 案例

以下代码用来演示 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 错误:

 for (int i = 3; i >= 0; i--) {
  try {
    int[] arr = new int[Integer.MAX_VALUE-i];
    System.out.format("Successfully initialized an array with %,d elements.\n", Integer.MAX_VALUE-i);
  } catch (Throwable t) {
    t.printStackTrace();
  }
}

其中,for循环迭代4次, 每次都去初始化一个 int 数组, 长度从 Integer.MAX_VALUE-3 开始递增, 到 Integer.MAX_VALUE 为止. 在 64位 Mac OS X 的 Hotspot 7 平台上, 执行这段代码会得到类似下面这样的结果:
在这里插入图片描述
请注意, 在后两次迭代抛出 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 错误之前, 先抛出了2次 java.lang.OutOfMemoryError: Java heap space 错误。 这是因为 2^31-1 个 int 数占用的内存超过了JVM默认的8GB堆内存。

此示例也展示了这个错误比较罕见的原因 —— 要取得JVM对数组大小的限制, 要分配长度差不多等于 Integer.MAX_INT 的数组. 这个示例运行在64位的Mac OS X, Hotspot 7平台时, 只有两个长度会抛出这个错误: Integer.MAX_INT-1 和 Integer.MAX_INT。

3. 解决方案

发生 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 错误的原因可能是:

  1. 数组太大, 最终长度超过平台限制值, 但小于 Integer.MAX_INT
  2. 为了测试系统限制, 故意分配长度大于 2^31-1 的数组。

第一种情况, 需要检查业务代码, 确认是否真的需要那么大的数组。如果可以减小数组长度, 那就万事大吉. 如果不行,可能需要把数据拆分为多个块, 然后根据需要按批次加载。

如果是第二种情况, 请记住, Java 数组用 int 值作为索引。所以数组元素不能超过 2^31-1 个. 实际上, 代码在编译阶段就会报错,提示信息为 “error: integer number too large”。

如果确实需要处理超大数据集, 那就要考虑调整解决方案了. 例如拆分成多个小块,按批次加载; 或者放弃使用标准库,而是自己处理数据结构,比如使用 sun.misc.Unsafe 类, 通过Unsafe工具类可以像C语言一样直接分配内存。

8. Kill process or sacrifice child

1. 概述

我们知道, 操作系统(operating system)构建在进程(process)的基础上. 进程由内核作业(kernel jobs)进行调度和维护, 其中有一个内核作业称为 “Out of memory killer(OOM终结者)”, 与本节所讲的 OutOfMemoryError 有关。

Out of memory killer 在可用内存极低的情况下会杀死某些进程。只要达到触发条件就会激活, 选中某个进程并杀掉。 通常采用启发式算法, 对所有进程计算评分(heuristics scoring), 得分最低的进程将被 kill 掉。因此 Out of memory: Kill process or sacrifice child 和前面所讲的 OutOfMemoryError 都不同, 因为它既不由JVM触发,也不由JVM代理, 而是系统内核内置的一种安全保护措施。

如果可用内存(含swap)不足, 就有可能会影响系统稳定, 这时候 Out of memory killer 就会设法找出流氓进程并杀死他, 也就是引起 Out of memory: kill process or sacrifice child 错误。
在这里插入图片描述
默认情况下, Linux kernels(内核)允许进程申请的量超过系统可用内存. 这是因为,在大多数情况下, 很多进程申请了很多内存, 但实际使用的量并没有那么多.
有个简单的类比, 宽带租赁的服务商, 可能他的总带宽只有 10Gbps, 但却卖出远远超过100份以上的 100Mbps 带宽, 原因是多数时候, 宽带用户之间是错峰的, 而且不可能每个用户都用满服务商所承诺的带宽。

这样的话,可能会有一个问题, 假若某些程序占用了大量的系统内存, 那么可用内存量就会极小, 导致没有内存页面(pages)可以分配给需要的进程。可能这时候会出现极端情况, 就是 root 用户也不能通过 kill 来杀掉流氓进程. 为了防止发生这种情况, 系统会自动激活 killer, 查找流氓进程并将其杀死。

更多关于 ”Out of memory killer“ 的性能调优细节, 请参考: RedHat 官方文档

现在我们知道了为什么会发生这种问题, 那为什么是半夜5点钟触发 “killer” 发报警信息给你呢? 通常触发的原因在于操作系统配置. 例如, /proc/sys/vm/overcommit_memory 配置文件的值, 指定了是否允许所有的 malloc() 调用成功. 请注意, 在各操作系统中, 这个配置对应的 proc 文件路径可能不同。

过量使用(overcommitting)配置, 允许流氓进程申请越来越多的内存, 最终惹得 ”Out of memory killer“ 出来搞事情。

2. 案例

在Linux上(如最新稳定版的Ubuntu)编译并执行以下的示例代码:

package eu.plumbr.demo;

public class OOM {

public static void main(String[] args){
  java.util.List<int[]> l = new java.util.ArrayList();
  for (int i = 10000; i < 100000; i++) {
      try {
        l.add(new int[100_000_000]);
      } catch (Throwable t) {
        t.printStackTrace();
      }
    }
  }
}

将会在系统日志中(如 /var/log/kern.log 文件)看到一个错误, 类似这样:
在这里插入图片描述
提示: 可能需要调整 swap 的大小并设置最大堆内存, 例如堆内存配置为 -Xmx2g, swap 配置如下:

swapoff -a 
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile
3. 解决方案

有多种处理办法。最简单的办法就是将系统迁移到内存更大的实例中。

另外, 还可以通过 OOM killer 调优, 或者做负载均衡(水平扩展,集群), 或者降低应用对内存的需求。

不太推荐的方案是加大交换空间/虚拟内存(swap space)。 试想一下, Java 包含了自动垃圾回收机制, 增加交换内存的代价会很高昂. 现代GC算法在处理物理内存时性能飞快, 但对交换内存来说,其效率就是硬伤了. 交换内存可能导致GC暂停的时间增长几个数量级, 因此在采用这个方案之前, 看看是否真的有这个必要。

3. 其他场景

JAVA 线上故障排查完整套路!牛掰!

1. CPU 飙升

当程序响应变慢的时候,首先使用 top 、vmstat 、ps 等命令查看系统的 CPU 使用率是否有异常,从而可以判断出是否是 CPU 繁忙造成的性能问题。其中,主要通过 us(用户进程所占的%)这个数据来看异常的进程信息。当 us 接近 100% 甚至更高时,可以确定是 CPU 繁忙造成的响应缓慢。一般说来,CPU 繁忙的原因有以下几个:

  1. 线程中有无限空循环 、无阻塞 、正则匹配或者单纯的计算
  2. 发生了频繁的 gc
  3. 多线程的上下文切换
1. CPU 分析
  1. 找到最耗 CPU 的进程:指令是 top
    在这里插入图片描述
  2. 找到指定进程下最耗 CPU 的线程:指令是 top -Hp pid
    在这里插入图片描述
  3. 确定好 CPU 使用率最高的进程之后就可以使用 jstack 来打印出异常进程的堆栈信息:指令是jstack pid
  4. 过滤指定线程,打印堆栈信息:指令是 jstack pid |grep 'threadPid' -C5 --color,表示打印进程堆栈并通过线程id,过滤得到线程堆栈信息`
    在这里插入图片描述
    需要注意的是线程的状态:RUNNABLE 、WAITING 等。对于 RUNNABLE 的进程需要注意是否有耗费 CPU 的计算。对于 WAITING 的线程一般是锁的等待操作。
  5. 也可以使用 jstat 来查看对应进程的 gc 信息,以判断是否是 gc 造成了 CPU 繁忙,指令是 jstat -gcutil pid
    在这里插入图片描述
  6. 还可以通过 vmstat,通过观察内核状态的上下文切换( cs )次数,来判断是否是上下文切换造成的 CPU 繁忙,指令是vmstat 1 5
    在这里插入图片描述
  7. 此外,有时候可能会由 JIT 引起一些 CPU 飚高的情形,如大量方法编译等。这里可以使用 -XX:+PrintCompilation这个参数输出 JIT 编译情况,以排查 JIT 编译引起的 CPU 问题
2. CPU 调优
  1. 不要存在一直运行的线程(无限 while 循环),可以使用 sleep 休眠一段时间。这种情况普遍存在于一些 pull 方式消费数据的场景下,当一次 pull 没有拿到数据的时候建议 sleep 一下,再做下一次 pull
  2. 轮询的时候可以使用 wait/notify 机制
  3. 避免循环 、正则表达式匹配 、计算过多,包括使用 String 的 format 、split 、replace 方法(可以使用 apache 的 commons-lang 里的 StringUtils 对应的方法),使用正则去判断邮箱格式(有时候会造成死循环) 、序列/反序列化等
  4. 结合 JVM 和代码,避免产生频繁的 gc,尤其是 Full GC

此外,使用多线程的时候,还需要注意以下几点:

  1. 使用线程池,减少线程数以及线程的切换
  2. 多线程对于锁的竞争可以考虑减小锁的粒度(使用 ReetrantLock ) 、拆分锁(类似 ConcurrentHashMap 分 bucket 上锁), 或者使用 CAS 、ThreadLocal 、不可变对象等无锁技术。此外,多线程代码的编写最好使用 jdk 提供的并发包 、Executors 框架以及 ForkJoin 等,此外 DiscuptorActor 在合适的场景也可以使用

2. 内存问题

1. 内存分析

对 Java 应用来说,内存主要是由堆外内存和堆内内存组成

  1. 堆外内存

    堆外内存主要是 JNI 、Deflater/Inflater 、DirectByteBuffer(nio 中会用到)使用的。对于这种堆外内存的分析,还是需要先通过 vmstat 、sar 、top 、pidstat (这里的 sar,pidstat 以及 iostat 都是sysstat软件套件的一部分,需要单独安装)等查看 swap 和物理内存的消耗状况再做判断的。此外,对于 JNI 、Deflater 这种调用可以通过 Google-preftools 来追踪资源使用状况。

  2. 堆内内存

    此部分内存为 Java 应用主要的内存区域。通常与这部分内存性能相关的有:

    • 创建的对象:这个是存储在堆中的,需要控制好对象的数量和大小,尤其是大的对象很容易进入老年代
    • 全局集合:全局集合通常是生命周期比较长的,因此需要特别注意全局集合的使用
    • 缓存:缓存选用的数据结构不同,会很大程序影响内存的大小和 gc
    • ClassLoader:主要是动态加载类容易造成永久代内存不足
    • 多线程:线程分配会占用本地内存,过多的线程也会造成内存不足
      .

    以上使用不当很容易造成:

    • 频繁 GC -> Stop the world,使你的应用响应变慢
    • OOM,直接造成内存溢出错误使得程序退出。
      .

    排查堆内存问题的常用工具是 jmap,是 jdk 自带的。一些常用用法如下:

    • 查看 jvm 内存使用状况:jmap -heap
    • 查看 jvm 内存存活的对象:jmap -histo:live
    • 把 heap 里所有对象都 dump 下来,无论对象是死是活:jmap -dump:format=b,file=xxx.hprof
    • 先做一次 full GC,再 dump,只包含仍然存活的对象信息:jmap -dump:format=b,live,file=xxx.hprof
      .

    此外,不管是使用 jmap 还是在 OOM 时产生的 dump 文件,可以使用 Eclipse 的 MAT(MEMORY ANALYZER TOOL)来分析,可以看到具体的堆栈和内存中对象的信息。当然 jdk 自带的 jhat 也能够查看 dump 文件(启动 web 端口供开发者使用浏览器浏览堆内对象的信息)。此外,VisualVM 也能够打开 hprof (其实这个后缀名可以是任意字面量) 文件,使用它的 heap walker 查看堆内存信息

2. 内存调优

内存的调优主要就是对 JVM 的调优

  1. 合理设置各个代的大小。避免新生代设置过小(不够用,经常 minor gc 并进入老年代)以及过大(会产生碎片),同样也要避免 Survivor 设置过大和过小
  2. 选择合适的 GC 策略。需要根据不同的场景选择合适的 gc 策略。这里需要说的是,cms 并非全能的。除非特别需要再设置,毕竟 cms 的新生代回收策略 parnew 并非最快的,且 cms 会产生碎片。JDK 8 开始,推荐使用 G1
  3. jvm 启动参数配置 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:[log_path],以记录 gc 日志,便于排查问题。

其中,对于第一点,具体的还有一点建议:

  1. 年轻代大小选择:响应时间优先的应用,尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生 gc 的频率是最小的。同时,也能够减少到达年老代的对象。吞吐量优先的应用,也尽可能的设置大,因为对响应时间没有要求,垃圾收集可以并行进行,建议适合 8 CPU 以上的应用使用

  2. 老年代大小选择
    响应时间优先的应用,老年代一般都是使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,会造成内存碎片 、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:

    • 并发垃圾收集信息
    • 持久代并发收集次数
    • 传统 GC 信息
    • 花在年轻代和年老代回收上的时间比例

一般吞吐量优先的应用都应该有一个很大的年轻代和一个较小的年老代。这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代存放长期存活对象

代码上,也需要注意:

  1. 避免保存重复的 String 对象,同时也需要小心 String.subString() 与 String.intern() 的使用,尤其是后者其底层数据结构为 StringTable,当字符串大量不重复时,会使得 StringTable 非常大(一个固定大小的 hashmap,可以由参数 -XX:StringTableSize=N 设置大小),从而影响 young gc 的速度。在 jackson 和 fastjson 中使用了此方法,某些场景下会引起 GC 问题: YGC越来越慢,为什么

  2. 尽量不要使用 finalizer

  3. 释放不必要的引用:ThreadLocal 使用完记得释放以防止内存泄漏,各种 Stream 使用完也记得 close

  4. 使用对象池避免无节制创建对象,造成频繁 gc。但不要随便使用对象池,除非像连接池 、线程池这种初始化/创建资源消耗较大的场景

  5. 缓存失效算法,可以考虑使用 SoftReference 、WeakReference 保存缓存对象

  6. 谨慎热部署/加载的使用,尤其是动态加载类等

  7. 不要用 Log4j 输出文件名 、行号,因为 Log4j 通过打印线程堆栈实现,会生成大量 String。此外,使用 log4j 时,建议此种经典用法:先判断对应级别的日志是否打开,再做操作,否则也会生成大量 String

    if (logger.isInfoEnabled()) {
        logger.info(msg);
    }
    

3. 线程死锁

有时候部署场景会有线程死锁的问题发生(并不常见),我们采用 jstack 查看一下。比如说我们现在已经有一个线程死锁的程序,导致某些操作 waiting 中

  1. 查看 Java 进程 id:top 或者 jps
    在这里插入图片描述
  2. 查看 Java 进程的线程快照信息:jstack -l pid
    在这里插入图片描述
    从输出信息可以看到,有一个线程死锁发生,并且指出了是哪行代码出现的。如此可以快速排查问题

4. IO 问题

1. IO 分析

通常与应用性能相关的包括:文件 IO 和网络 IO

1. 文件 IO

可以使用系统工具 pidstat 、iostat 、vmstat 来查看 io 的状况
使用 vmstat 的结果图:
在这里插入图片描述
这里主要注意 bi 和 bo 这两个值,分别表示块设备每秒接收的块数量和块设备每秒发送的块数量,由此可以判定 IO 繁忙状况。进一步的可以通过使用 strace 工具来定位对文件 IO 的系统调用。通常,造成文件 IO 性能差的原因不外乎:

  1. 大量的随机读写
  2. 设备慢
  3. 文件太大
2. 网络 IO

查看网络 IO 状况,一般使用的是 netstat 工具。可以查看所有连接的状况 、数目 、端口信息等。例如:当 time_wait 或者 close_wait 连接过多时,会影响应用的相应速度

使用 netstat -anp
在这里插入图片描述
此外,还可以使用 tcpdump 来具体分析网络 IO 的数据。当然,tcpdump 出的文件直接打开是一堆二进制的数据,可以使用 wireshark 阅读具体的连接以及其中数据的内容

使用 tcpdump -i eth0 -w tmp.cap -tnn dst port 8080 监听 8080 端口的网络请求并打印日志到 tmp.cap 中,还可以通过查看 /proc/interrupts 来获取当前系统使用的中断的情况:
在这里插入图片描述
各个列依次是:irq的序号, 在各自cpu上发生中断的次数,可编程中断控制器,设备名称(request_irq的dev_name字段)

通过查看网卡设备的终端情况可以判断网络 IO 的状况

2. IO 调优

文件 IO 上需要注意:

  • 考虑使用异步写入代替同步写入,可以借鉴 redis 的 aof 机制
  • 利用缓存,减少随机读
  • 尽量批量写入,减少 io 次数和寻址
  • 使用数据库代替文件存储

网络 IO 上需要注意:

  • 和文件 IO 类似,使用异步 IO 、多路复用 IO/事件驱动 IO代替同步阻塞 IO
  • 批量进行网络 IO,减少 IO 次数
  • 使用缓存,减少对网络数据的读取
  • 使用协程: Quasar

4. JVM 常用参数

1. GC 常用参数

  • -Xmn -Xms -Xmx -Xss
    年轻代 最小堆 最大堆 栈空间
  • -XX:+UseTLAB
    使用TLAB,默认打开
  • -XX:+PrintTLAB
    打印TLAB的使用情况
  • -XX:TLABSize
    设置TLAB大小
  • -XX:+DisableExplictGC
    System.gc()不管用 ,FGC
  • -XX:+PrintGC
  • -XX:+PrintGCDetails
  • -XX:+PrintHeapAtGC
  • -XX:+PrintGCTimeStamps
  • -XX:+PrintGCApplicationConcurrentTime (低)
    打印应用程序时间
  • -XX:+PrintGCApplicationStoppedTime (低)
    打印暂停时长
  • -XX:+PrintReferenceGC (重要性低)
    记录回收了多少种不同引用类型的引用
  • -verbose:class
    类加载详细过程
  • -XX:+PrintVMOptions
  • -XX:+PrintFlagsFinal -XX:+PrintFlagsInitial(必须会用)
    打印所有的JVM参数 查看所有JVM参数启动的初始值
  • -Xloggc:opt/log/gc.log
    将日志文件保存到指定路径
  • -XX:MaxTenuringThreshold
    升代年龄,最大值15
  • 锁自旋次数 -XX:PreBlockSpin 热点代码检测参数-XX:CompileThreshold 逃逸分析 标量替换 …
    这些不建议设置

2. Parallel 常用参数

  • -XX:SurvivorRatio
    Eden区域和Survivor区域的比例
  • -XX:PreTenureSizeThreshold
    大对象到底多大
  • -XX:MaxTenuringThreshold
  • -XX:+ParallelGCThreads
    并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同
  • -XX:+UseAdaptiveSizePolicy
    自动选择各区大小比例,自适应GC策略的开关参数,这是与 ParNew 最大的区别所在

3. CMS 常用参数

  • -XX:+UseConcMarkSweepGC
  • -XX:ParallelCMSThreads
    CMS线程数量
  • -XX:CMSInitiatingOccupancyFraction
    使用多少比例的老年代后开始CMS收集,默认是68%(近似值)(jdk 1.8 开始默认是 92%),如果频繁发生SerialOld卡顿,应该调小,(频繁CMS回收)
  • -XX:+UseCMSCompactAtFullCollection
    在FGC时进行压缩
  • -XX:CMSFullGCsBeforeCompaction
    多少次FGC之后进行压缩
  • -XX:+CMSClassUnloadingEnabled
  • -XX:CMSInitiatingPermOccupancyFraction
    达到什么比例时进行Perm回收
  • -XX:GCTimeRatio
    设置GC时间占用程序运行时间的百分比
  • -XX:MaxGCPauseMillis
    停顿时间,是一个建议时间,GC会尝试用各种手段达到这个时间,比如减小年轻代

4. G1 常用参数

  • -XX:+UseG1GC
  • -XX:MaxGCPauseMillis
    建议值,G1会尝试调整Young区的块数来达到这个值
  • -XX:GCPauseIntervalMillis
    ?GC的停顿间隔时间
  • -XX:+G1HeapRegionSize
    分区大小,建议逐渐增大该值,1 2 4 8 16 32。
    随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长
    ZGC做了改进(动态区块大小)
  • -XX:G1NewSizePercent
    新生代最小比例,默认为5%
  • -XX:G1MaxNewSizePercent
    新生代最大比例,默认为60%
  • -XX:GCTimeRatio
    GC时间建议比例,G1会根据这个值调整堆空间
  • -XX:ConcGCThreads
    线程数量
  • -XX:InitiatingHeapOccupancyPercent
    启动G1的堆空间占用比例
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值