JVM调优实战(二)- 生产OOM案例

2 阅读15分钟

JVM、OOM、内存泄漏、内存溢出、GC

背景

  接上篇《JVM调优实战(一)》,本节主要介绍生产环境常见的异常类型及处理办法。从实战的角度细说:如何发现问题、分析问题、解决问题。

文章导读

生产问题

  在生产环境中,JVM可能出现,如内存泄漏、内存溢出、线程死锁、GC停顿和性能问题等。

  • 如何处理CPU负载飙高?
  • 如何处理内存溢出?
  • 如何合理分配服务器内存?
  • 如何合理分配线程数?
  • 如何选择合适的垃圾回收器?
  • 如何优化减少频繁Full GC?

......

  出现上述问题后,势必会引起响应变慢、接口超时、系统卡顿等。通常,我们要分析具体业务场景。并通过系统监控发现问题。

调优步骤

发现问题(性能监控)

1、性能指标

  • 响应时间(RT): 提交一次请求和返回该请求的响应之间使用的时间,一般指平均响应时间.常见操作例如:打开一个网站;查询一条SQL;一次远程调用请求;下游系统数据同步等
  • 吞吐量: 吞吐量是指在单位时间内系统能处理的请求数量,它体现了系统处理请求的能力,通常反映的是服务器负载的能力。
  • QPS: 即每秒查询率,是评估系统查询处理能力的关键指标。 QPS = 总查询次数 / 总时间(秒).该指标反映系统在单位时间内能够成功处理的查询请求数量.
  • 并发数: 是指系统在同一时刻实际处理的请求或事务的数量,反映了系统可以同时承载的正常使用系统功能的用户的数量。一般来说,并发数越高,系统的处理能力越强。

2、性能问题

  • 频繁Full GC
  • CPU负载高
  • 死锁
  • OOM
  • 内存泄漏

这里给出一个重要的top命令。主要用于排查:内存不断增长,CPU占用率居高不下的进程。 top 查看所有的进程的cpu、内存占比。 top -Hp pid 查看指定pid下各个线程的cpu、内存占比。

top
PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                    
9757 root      20   0 5173452 140856  14168 S  99.7  1.8  66:19.52 java  

分析问题(性能分析)

  • 命令行工具,jstack,jmap,jinfo等
  • 导出heapdump.hprof文件,使用内存分析工具MAT分析
  • 使用JVisualVM或者阿里Arthas来实时查看JVM状态
  • 打印GC日志,通过GCviewer或者 gceasy.io来分析日志信息

解决问题(性能调优)

  • 优化代码,比如循环调用、批量处理等。
  • 加机器节点,提高负载能力
  • 使用中间件提高吞吐量,比如缓存,异步等
  • 通过调整参数配置,例如:合理增加线程数
  • 选取合适的垃圾回收器

复盘问题(性能考量)

在业务系统中,各种场景都有可能导致系统性能问题。例如:

  • 定时任务处理日增量数据:如果定时任务执行频率过高或处理逻辑复杂,可能导致系统资源(如CPU、内存、I/O)的频繁占用,从而影响其他任务的执行性能。此外,如果任务处理时间过长,还可能影响系统的响应时间。
  • 远程第三方平台调用:可能涉及网络延迟和数据传输。如果第三方平台响应缓慢或数据传输量大,可能导致系统等待时间过长,从而影响整体性能。
  • 微服务之间接口调用:微服务架构中,服务间的调用可能涉及网络通信和序列化/反序列化操作,这些操作都会带来一定的性能开销。如果服务调用链过长或调用频繁,可能导致系统性能下降。同时,不同服务之间的负载均衡和容错机制也会影响整体性能。
  • 远程获取Redis和MQ消息:从Redis和MQ中获取消息时,如果网络延迟较高或消息队列积压严重,可能导致系统等待时间过长,从而影响性能。此外,频繁地从Redis中读取或写入数据也可能导致性能瓶颈。
  • 批量处理割接数据:批量处理大量数据可能导致系统资源(如内存、CPU)的占用率急剧上升,从而影响其他任务的执行。
  • 大文件上传下载:处理大文件上传下载时,如果文件过大或网络带宽有限,可能导致上传下载速度缓慢,从而影响用户体验和系统性能。同时,处理大文件还可能带来磁盘I/O和内存占用的压力。
  • 复杂业务接口查询:复杂的业务接口查询可能涉及多表联查、大量数据计算和聚合等操作,这些操作都可能消耗大量的系统资源,从而影响性能。

  通常在各种应用场景中,我们不仅要对线上产生的问题进行复盘、分享、交流。而且要保持对业务的深刻理解和上线之前的数据量预估,进而对系统性能做出考量。

知道上述的一些基本步骤。接下来,重点聊聊关于JVM性能调优-OOM的案例。

性能优化案例

基础配置

  • 主机参数:4核8G
  • Oracle jdk版本:1.8.0_221

本节演示主机参数:4核8G。真实生产环境可能配置较高,具体在Linux系统下查看内存和CPU核数命令如下

查看内存:

  • free -h: 显示内存信息
  • cat /proc/meminfo: 显示内存的详细信息,包括总内存、可用内存等。

查看CPU核数:

  • nproc命令直接输出CPU核心数,是最简单的方法。
  • lscpu命令可以显示CPU的详细信息,包括CPU核心数、架构等。
  • cat /proc/cpuinfo文件也可以获取CPU的相关信息,如物理CPU个数、每个物理CPU的核数等,可以通过grep命令过滤出需要的信息,如cat /proc/cpuinfo | grep "processor" | wc -l可以查看逻辑CPU的个数。

案例一、OOM-堆内存溢出

异常提示:java.lang.OutOfMemoryError: Java heap space

示例代码:

  List<Object> list = new ArrayList<>();
  while (true) {
      list.add(new Object());
  }

参数配置:

官方参考文档:docs.oracle.com/javase/8/do…

这里为了演示效果,为自定义配置

参数配置: 初始 -Xms20m  最大 -Xmx20m
java -jar -Xms20m -Xmx20m -XX:MetaspaceSize=8m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdump.hprof -XX:+PrintGCDetails -XX:+PrintGCDateStamps   -Xloggc:log/gc-oom-heapspace.log demo-jvm-1.0-SNAPSHOT.jar

演示结果:

监控与分析: MAT 分析dump文件

查看日志文件,也可用工具查看

less -SR /root/log/gc-oom-heapspace.log

原因分析:

1、代码中可能存在大对象分配;

2、可能存在内存泄漏,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。

解决办法:

1、通过问题定位检查是否存在大对象的分配,可能的是大数组或者死循环;

2、使用MAT等工具分析堆内存dump文件,检查是否存在内存泄漏;

3、如果没有找到明显的内存泄漏,使用 -Xmx 加大堆内存

4、容易忽视的是:也有可能是使用框架内部造成的

发生场景:

场景 1:大数据处理

  当应用程序需要处理大量数据,并且这些数据无法全部加载到内存中时,可能会导致堆内存溢出。

案例代码:

public class BigDataProcessor {
    public static void main(String[] args) {
        List<String> bigDataList = new ArrayList<>();
        // 假设这是从数据库或文件中读取的大量数据
        for (int i = 0; i < 100000000; i++) {
            bigDataList.add(String.valueOf(i));
        }
        // 在处理完数据之前,程序可能会因为堆内存不足而崩溃
    }
}

场景 2:内存泄漏

  长时间持有不再需要的对象引用,导致这些对象无法被垃圾回收,最终耗尽堆内存。

案例代码:

public class MemoryLeakDemo {
    private static List<Object> leakedList = new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            Object obj = new Object();
            leakedList.add(obj); // 添加到静态列表中,导致对象无法被回收
            // 没有从leakedList中移除对象的逻辑,造成内存泄漏
            try {
                Thread.sleep(100); // 模拟其他处理
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

场景 3:集合类使用不当

  使用集合类时,如果集合对象本身很大或集合中存储的对象很大,并且这些对象不再需要但未被及时清理,可能导致内存溢出。

案例代码

public class CollectionMisuse {
    public static void main(String[] args) {
        List<Bitmap> bitmaps = new ArrayList<>();
        // 假设Bitmap是一个大型对象,如位图图像
        for (int i = 0; i < 1000000; i++) {
            bitmaps.add(new Bitmap(20002000)); // 创建一个大型位图对象并添加到列表中
        }
        // 如果这些位图对象不再需要,但没有从列表中移除,将导致内存溢出
    }
}

场景 4:缓存使用不当

  使用缓存时,如果缓存对象持续增长而没有被有效地管理和清理,可能会耗尽堆内存。

案例代码:

import java.util.HashMap;
import java.util.Map;

public class CacheMismanagement {
    private static Map<StringObject> cache = new HashMap<>();

    public static void main(String[] args) {
        while (true) {
            String key = String.valueOf(System.currentTimeMillis()); // 使用时间戳作为键
            Object value = new Object(); // 创建一个新对象作为值
            cache.put(key, value); // 将对象放入缓存中,但不进行任何清理
            try {
                Thread.sleep(100); // 模拟其他处理
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

场景 5:第三方库使用不当

某些第三方库可能存在内存泄漏问题,如果应用程序不当使用这些库,也可能导致堆内存溢出。

案例代码:

import com.example.ThirdPartyLibrary// 假设的第三方库

public class ThirdPartyLeak {
    public static void main(String[] args) {
        while (true) {
            ThirdPartyLibrary library = new ThirdPartyLibrary(); // 不断创建第三方库的对象
            library.doSomething(); // 使用第三方库的功能
            // 假设ThirdPartyLibrary存在内存泄漏问题,而对象没有被正确释放
            try {
                Thread.sleep(100); // 模拟其他处理
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在这些场景中,应用程序可能会因为无法分配更多的堆内存而抛出OutOfMemoryError。要避免这些问题,需要我们密切关注内存使用情况,使用分析工具来检测内存泄漏,并合理管理应用程序中的内存资源。同时,应合理设置JVM的堆内存大小。

案例二、OOM-元空间溢出

异常提示:java.lang.OutOfMemoryError: Metaspace

  元空间溢出通常发生在应用程序加载了大量类的情况下,特别是在使用动态类加载、复杂的类结构或者某些第三方库时。

生产案例:

  假设我们有一个Spring应用程序,其中使用了AOP来为每个服务层的Bean创建代理,用于事务管理和日志记录。以下是一个简单的例子,展示了如何通过配置Spring AOP来避免不必要的类创建,从而减少元空间的使用。

配置示例(可能导致元空间溢出):

@Configuration
@EnableAspectJAutoProxy // 启用AspectJ自动代理
public class AppConfig {
    // ... 其他配置
}

@Aspect
@Component
public class LoggingAspect {
    
    @Before("execution(* com.example.service.*.*(..))")
    public void logBefore(JoinPoint joinPoint) {
        // 记录日志
    }

    // 可能还有其他通知方法
}

在这个配置中,@EnableAspectJAutoProxy注解告诉Spring为所有匹配的Bean创建代理。如果com.example.service包下有很多服务层的Bean,那么每个Bean都会创建一个代理,增加了元空间的负担。

为了避免不必要的类创建和元空间溢出,我们可以优化只针对需要AOP功能的Bean创建代理。这可以通过使用@AspectJProxyFactory或更细粒度的@EnableAspectJAutoProxy注解参数来实现。

优化后的配置示例:

@Configuration
public class AppConfig {

    @Bean
    public DefaultPointcutAdvisor advisor() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(* com.example.service.SomeSpecificService.*(..))");

        LoggingAspect aspect = new LoggingAspect();
        return new DefaultPointcutAdvisor(pointcut, aspect);
    }

    // ... 其他配置
}

  优化后的配置中,我们显式地定义了一个DefaultPointcutAdvisor,它只会为com.example.service.SomeSpecificService这个特定的Bean创建代理。这样,只有那些真正需要AOP功能的Bean才会被代理,从而减少了不必要的类创建和元空间的使用。

当然这种异常也可能是元空间设置太小造成的,可以优化设置参数:

解决办法:

java -XX:MaxMetaspaceSize=1g -jar App.jar

案例三、GC overhead limit exceeded

异常提示:GC overhead limit exceeded

生产案例:

场景 1:并发线程处理

  在一个高并发的在线支付系统中,由于每个支付请求都需要创建一个新的处理线程,系统在高峰时段会同时处理数千个支付请求,从而创建了大量的线程。由于短时间内产生大量垃圾对象,垃圾回收器频繁触发,占用了大量的计算资源,导致实际垃圾回收时间超过了98%的执行时间,最终抛出GC overhead limit exceeded异常。

示例代码:

public class PaymentProcessingThread extends Thread {
    private PaymentRequest request;

    public PaymentProcessingThread(PaymentRequest request) {
        this.request = request;
    }

    @Override
    public void run() {
        // 处理支付请求
        processPayment(request);

        // 处理完成后,相关对象不再需要
        request = null;
        // ... 其他短期对象也设置为null
    }

    private void processPayment(PaymentRequest request) {
        // 模拟支付处理逻辑
        // 创建并处理多个短期对象
        String paymentId = request.getPaymentId();
        BigDecimal amount = request.getAmount();
        // ... 执行支付逻辑

        // 支付完成后,清理资源
        paymentId = null;
        amount = null;
    }
}

// 在支付服务中创建并启动线程
public class PaymentService {
    public void processPayments(List<PaymentRequest> requests) {
        for (PaymentRequest request : requests) {
            new PaymentProcessingThread(request).start();
        }
    }
}

// 在实际应用中,可能会有更多逻辑,如线程池管理、错误处理等

解决办法:

1.调整JVM参数:

  • 增大堆内存大小(通过-Xmx参数),以容纳更多的对象。
  • 选择更高效的垃圾收集器,如G1GC(通过-XX:+UseG1GC参数)。
java --Xmx=2g -XX:+UseG1GC -jar App.jar
  1. 使用线程池:

    • 引入ExecutorService来管理线程池,替代直接创建线程。这样可以复用已有线程,减少线程创建和销毁的开销。
   ExecutorService executor = Executors.newFixedThreadPool(100); // 设定合适的线程池大小
   for (PaymentRequest request : requests) {
      executor.submit(() -> processPayment(request));
   }
   executor.shutdown();

  通过实施上述措施,可以显著减少垃圾回收的开销,提高系统处理能力和稳定性,从而避免GC overhead limit exceeded异常的发生。

场景 2:大数据处理

  假设我们有一个处理大量图片并提取特征的应用,由于特征提取过程中会创建大量临时对象,导致频繁GC且可能触发GC overhead limit exceeded异常。

核心代码:

import java.lang.ref.SoftReference;
import java.util.HashMap;

public class ImageProcessor {
    private HashMap<String, SoftReference<ImageFeature>> cache = new HashMap<>();

    public void processImage(String imageId, byte[] imageData) {
        ImageFeature feature = extractFeature(imageData);
        cache.put(imageId, new SoftReference<>(feature));

        // 清理旧数据逻辑(例如基于大小或时间)
        if (cache.size() > MAX_CACHE_SIZE) {
            cache.remove(cache.keySet().iterator().next());
        }
    }
    
     private ImageFeature extractFeature(byte[] imageData) {  
        // 模拟图像处理,提取特征  
        return new ImageFeature();  
    }  
  
    // ImageFeature 类定义  
    static class ImageFeature {  
        // 特征数据  
    }  

    public static void main(String[] args) {
        ImageProcessor processor = new ImageProcessor();
        // 处理图片
        for (int i = 0; i < 1000; i++) {
            processor.processImage("image" + i, new byte[1024 * 1024]);
        }
    }
}

解决办法:

  为了避免GC overhead limit exceeded异常,我们使用SoftReference来存储提取的图片特征。当系统内存压力增大时,软引用对象可以被垃圾收集器回收。此外,我们实现了一个简单的缓存管理机制,当缓存大小超过MAX_CACHE_SIZE时,移除最不常用的数据。

案例四、OOM-线程溢出

异常提示:java.lang.OutOfMemoryError : unable to create new native Thread

案例场景:

  假设我们有一个Web服务器,它使用多线程来处理客户端的并发请求。每个请求都会创建一个新的线程。当线程数量达到操作系统允许的最大线程数时,就会抛出java.lang.OutOfMemoryError: unable to create new native Thread错误。

代码示例:

public class ThreadCreationExample {

    public static void main(String[] args) {
        // 模拟一个Web服务器不断接收请求并创建线程处理
        while (true) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // 模拟处理请求的任务
                    try {
                        Thread.sleep(1000); // 假设每个请求处理需要1秒钟
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            }).start();

            // 休眠一段时间,模拟请求间隔
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

解决办法:

  1. 通过 -Xss 设置每个线程栈大小的容量

  在启动JVM时,可以通过-Xss选项来设置每个线程的栈大小。减少栈大小可以减少为每个线程分配的内存,这有助于在有限的内存资源下创建更多的线程。

java -Xss128k -jar your-application.jar

这里,-Xss128k设置了每个线程的栈大小为128KB。实际根据需要调整这个值。

  1. 调整系统允许的最大线程数

  在Linux系统中,可以通过修改/proc/sys/kernel/threads-max文件来调整系统允许的最大线程数。这需要使用root权限。

sudo sh -c 'echo 100000 > /proc/sys/kernel/threads-max'

这条命令将系统允许的最大线程数设置为100000。这个数字应根据系统资源和应用程序的需求来设置。

注意

  • 调整线程栈大小和最大线程数都可能会影响到应用程序的性能和稳定性。在修改这些设置之前,应该仔细考虑它们对系统的影响。

官网资料: docs.oracle.com/javase/8/do…

往期推荐

JVM调优实战(一)

分库分表设计及常见问题

项目实战中的异步设计

多级缓存设计和实战应用

如何选择分布式事务解决方案?

结尾

  共享即共赢。如有帮助,帮忙点赞和在看。关注公众号【码易有道】,定期更新一些工程实践的总结和个人心得。欢迎你的加入,一起学习、交流、做长期且正确的事情!!!