内存泄漏 与 内存溢出

1.内存溢出(Memory Overflow)

  • 生活样例:
            
    内存容量就像一个桶,内存就是水,水 溢出 就是水满了。
  • 定义:
            
    内存溢出是指程序试图使用超过其可用内存限制的内存。这种情况通常会导致程序崩溃或异常。内存溢出一般是由于分配了过多内存或者在使用数据结构时超出了其限制。
  • 例子:堆内存溢出
            堆内存用于动态分配对象。当程序尝试分配超过堆内存限制的内存时,就会发生堆内存溢出。
public class HeapMemoryoverflowExample {
    public static void main(string[] args) {
        List<int[]> list = new ArrayList<>();
        while (true) {
            list.add(new int[10000001);//不断分配大块内存
        }
    }
}

常见内存溢出情况及解决方案:

  • 堆内存溢出(Java Heap Space)

    • 原因:长时间运行的应用可能会持续创建对象,如果这些对象没有被及时回收,就可能导致堆内存耗尽。
    • 解决:增加JVM堆内存大小(通过-Xms-Xmx参数设置);优化代码以减少内存使用,比如使用对象池来减少对象创建;分析内存泄漏并修复。
  • 栈溢出(StackOverflowError)

    • 原因:通常是由于递归调用太深或循环创建了大量局部变量。
    • 解决:优化递归逻辑,确保有正确的终止条件;减少方法调用深度;优化循环逻辑,避免创建大量局部变量。
  • 元空间溢出(Metaspace)

    • 原因:Java 8 以后的版本使用元空间代替了永久代,用于存储类的元数据。如果类的元数据消耗过多内存,可能会触发元空间溢出。
    • 解决:增加元空间大小(通过-XX:MetaspaceSize-XX:MaxMetaspaceSize参数设置);优化代码以减少类加载。
  • 大对象处理不当

    • 原因:处理大型对象或集合时,可能会占用大量内存。
    • 解决:优化大对象的处理逻辑,比如分批处理、使用流式处理等。
  • 线程资源管理不当

    • 原因:线程创建过多,每个线程都有自己的栈空间,可能导致内存溢出。
    • 解决:合理管理线程资源,避免创建过多线程;使用线程池来复用线程。

2.内存泄露(Memory Leak)

  • 生活样例:
    桶破了,水漏出去了。桶中的水就相当于内存,慢慢的流失了
  • 定义:
            内存泄露是指程序在运行过程中动态分配内存后,没有正确地释放不再使用的内存,导致这些内存无法被再次分配和使用。长时间运行的程序如果存在内存泄露,会导致内存逐渐耗尽,最终可能导致系统性能下降或者程序崩溃。
  • 例子:
            在 Java 中,虽然有垃圾回收机制,但也可能出现内存泄露。例如,当某个对象不再需要但仍然被引用时,垃圾回收器无法回收该对象的内存。
     
    public class MemoryLeakExamplef
        public static void main(string[l args) {
            List<Object> list = new ArrayList<>();
            while (true) {
                list.add(new 0bject());// 对象不断增加,但没有被释放
            }
        }
    }

2.1 静态属性导致内存泄露

        会导致内存泄露的一种情况就是大量使用static静态变量。在Java中,静态属性的生命周期通常伴随着应用整个生命周期(除非ClassLoader符合垃圾回收的条件)。

public class StaticTest {
    public static List<Double> list = new ArrayList<>();

    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }

    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}

如果监控内存堆内存的变化,会发现在打印Point1和Point2之间,堆内存会有一个明显的增长趋势图。

但当执行完populateList方法之后,对堆内存并没有被垃圾回收器进行回收。

针对上述程序,如果将定义list的变量前的static关键字去掉,再次执行程序,会发现内存发生了具体的变化。VisualVM监控信息如下图:

对比两个图可以看出,程序执行的前半部分内存使用情况都一样,但当执行完populateList方法之后,后者不再有引用指向对应的数据,垃圾回收器便进行了回收操作

因此,我们要十分留意static的变量,如果集合或大量的对象定义为static的,它们会停留在整个应用程序的生命周期当中。而它们所占用的内存空间,本可以用于其他地方。

那么如何优化呢?第一,进来减少静态变量;第二,如果使用单例,尽量采用懒加载。

2.2 未关闭的资源

无论什么时候当我们创建一个连接或打开一个流,JVM都会分配内存给这些资源。比如,数据库链接、输入流和session对象。

忘记关闭这些资源,会阻塞内存,从而导致GC无法进行清理。特别是当程序发生异常时,没有在finally中进行资源关闭的情况。

这些未正常关闭的连接,如果不进行处理,轻则影响程序性能,重则导致OutOfMemoryError异常发生。

如果进行处理呢?

  • 第一,始终记得在finally中进行资源的关闭;
  • 第二,关闭连接的自身代码不能发生异常;
  • 第三,Java7以上版本可使用try-with-resources代码方式进行资源关闭。

2.3 不当的equals方法和hashCode方法实现

当我们定义个新的类时,往往需要重写equals方法和hashCode方法。在HashSet和HashMap中的很多操作都用到了这两个方法。如果重写不得当,会造成内存泄露的问题。

下面来看一个具体的实例:

public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
}

现在将重复的Person对象插入到Map当中。我们知道Map的key是不能重复的。

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<100; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}

上述代码中将Person对象作为key,存入Map当中。理论上当重复的key存入Map时,会进行对象的覆盖,不会导致内存的增长。

但由于上述代码的Person类并没有重写equals方法,因此在执行put操作时,Map会认为每次创建的对象都是新的对象,从而导致内存不断的增长。

VisualVM中显示信息如下图:

当重写equals方法和hashCode方法之后,Map当中便只会存储一个对象了。方法的实现如下:

public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }
    
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

经过上述修改之后,Assert中判断Map的size便会返回true。

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}

重写equals方法和hashCode方法之后,堆内存的变化如下图:

在这里插入图片描述

另外的例子就是当使用ORM框架,如Hibernate时,会使用equals方法和hashCode方法进行对象的的分析和缓存操作。

如果不重写这些方法,则发生内存泄漏的可能性非常高,因为Hibernate将无法比较对象(每次都是新对象),然后不停的更新缓存。

如何进行处理?

  • 第一,如果创建一个实体类,总是重写equals方法和hashCode方法;
  • 第二,不仅要覆盖默认的方法实现,而且还要考虑最优的实现方式;

2.4 外部类引用内部类

这种情况发生在非静态内部类(匿名类)中,在类初始化时,内部类总是需要外部类的一个实例。

每个非静态内部类默认都持有外部类的隐式引用。如果在应用程序中使用该内部类的对象,即使外部类使用完毕,也不会对其进行垃圾回收。

public class OuterClass {
    private String importantData;

    public OuterClass(String importantData) {
        this.importantData = importantData;
    }

    public void doSomething() {
        // 创建并启动线程,使用静态匿名内部类
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程运行中..." + importantData);
            }
        });
        thread.start();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass("重要数据");
        outerClass.doSomething();
        
        //尝试释放outerClass对象
        outerClass = null;
        
        //...其他业务代码
    }
}

         这段代码中main方法执行 outerClass = null; 如果匿名内部类开启的线程没有执行结束,outerClass由于还被引用,不会被垃圾回收!

         在这个例子中,内存泄漏的原因在于非静态匿名内部类(实现了Runnable接口的类)隐式地持有对其外部类实例OuterClass的引用。这个引用是通过importantData字段访问外部类的成员变量时建立的。即使在main方法中将outer变量设置为null,外部类实例OuterClass也不能被垃圾回收,因为匿名内部类中的线程仍然持有对它的引用。也就是说如果这个线程没有结束,引用就一直存在。

这里我们只需要拷贝一份局部变量,就可以解除这个引用,从而避免内存泄漏的问题。

public class OuterClass {
    private String importantData;

    public OuterClass(String importantData) {
        this.importantData = importantData;
    }

    public void doSomething() {
        // 创建并启动线程,使用静态匿名内部类
        String data = this.importantData; //定义个局部的final变量
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程运行中..." + data);
            }
        });
        thread.start();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass("重要数据");
        outerClass.doSomething();
    }
}

这样的话,匿名内部类中就不存在对外部类实例的引用。线程就不再直接引用OuterClass实例的成员变量,而是引用了一个局部变量的副本。因此,即使线程还在运行,一旦main方法中将outerClass变量设置为nullOuterClass的实例就可以被垃圾回收了。

2.5 ThreadLocal导致的内存泄漏

为什么源码要用弱引用?

        我们知道ThreadLocal是线程变量,每个线程都有一个配对的ThreadLocal对象(后面统称为tl,ThreadLocal tl = new ThreadLocal();)。tl在线程中被销毁时,该线程指向的ThreadLocal对象应该也被垃圾回收。ThreadLocalMap中的Entry中的key用的就是ThreadLocal的引用。如果这个引用为强引用。那么即使线程被销毁,如图指向ThreadLocal的引用tl也没有了。但是如果是强引用那么ThreadLocalMap中还有指向ThreadLocal的引用,导致对象无法被垃圾回收!因此要用弱引用。

        当function01方法执行完毕后,栈帧销毁,强引用tl也就没有了,但此时线程的ThreadLocalMap里某个entry的key引用还指向这个对象(因为即使执行function01方法的线程销毁了,ThreadLocal是另一个类,是两个不同的类,具有独立性)

举个例子:之前也说了这两个类之间的关系就像自然人 (Thread) 和 身份证 (ThreadLocal) 一样,人是人,身份证是身份证,是两样不同的东西。如果这个人去世了,这个身份证也应该作废销毁。一句话,身份证应该跟着人走。如果是强引用,就会出现人没了,但身份证信息还存在,这样信息就会越来越多导致内存溢出。

若这个Key是强引用,就会导致Key指向的ThreadLocal对象即V指向的对象不能被gc回收,造成内存泄露。

若这个引用时弱引用大概率会减少内存泄漏的问题(当然,还得考虑key为null这个坑),使用弱引用就可以使ThreadLocal对象在方法执行完毕后顺利被回收且entry的key引用指向为null。


现在我们已经知道了,Entry中使用弱引用就是为了避免内存泄漏。

 弱引用就万事大吉了吗?

        使用弱引用就可以使ThreadLocal对象在方法执行完毕后顺利被回收且entry的key引用指向为null。此后我们调用get、set或remove方法时,就会尝试删除key为null的entry,可以释放value对象所占用的内存

key为null的情况:

        1.当我们为threadLocal变量赋值,实际上就是当前的Entry(threadLocal实例为key,值为value)往这个threadLocalMap中存放。Entry中的key是弱引用,当threadLocal外部强引用(就是在指向当前ThreadLocal的线程中引用被置为null)被置为null (如 tl = null ) ,那么系统 GC 的时候,根据可达性分析,这个threadLocal实例就没有任何一条链路能够引用到它,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(这个t1就不会被干掉),这些key为null的Entry的value就会一直存在一条强引用链:Thread的引用 -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。

我们知道hashmap中允许key为null,可以看到这里存储了key为null的value,造成了内存泄漏

        2.当然,如果当前thread运行结束,threadLocal,threadLocalMap,Entry没有引用链可达,在垃圾回收的时候都会被系统进行回收。

        3.但在实际使用中我们有时候会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以threadLocal内存泄漏就值得我们小心。

总结:我们知道,一个Thread对应一个ThreadLocalMap,但可以在一个线程下创建多个ThreadLocal对象,每个ThreadLocal对象都可以作为ThreadLocalMap中的key。这时候,如果多个ThreadLocal对象使用完毕外部强引用赋值为null希望被垃圾回收 (new一个ThreadLocal对象的时候就是强引用,只是在ThreadLocalMap中的key是弱引用) 。这时候就出现了key为null但value还存在着的情况。如果这时候由于线程复用,Thread迟迟不结束,就会可能会导致越来越多的key为null (比如说现在ThreadLocalMap中有十个键值对,key从t1到t10,但这些ThreadLocal对象其实都已经被销毁了全部为null,下一次线程池复用该线程,这十个key对应的value是无法被内存释放的)

解决方法:set、get方法会去检查所有键为null的Entry对象

这些方法都对key== null 也就是脏Entry进行了处理,防止内存泄漏

  • set() 方法

  • get()方法

  • remove()方法


        ThreadLocal tl1 = new ThreadLocal();
        tl1.set("name");
        ThreadLocal tl2 = new ThreadLocal();
        tl2.set("滴滴滴");

        //在当前线程中,把强引用tl2设置为null,
        //当垃圾回收时,ThreadLocalMap中的弱引用也没有了,就没人指向tl2
        //这样的话当前Thread指向tl2就会被垃圾回收
        //此时就会出现ThreadLocalMap中的key为空,但value仍然存在的情况,导致内存泄漏
        //现在我们使用线程,一般都会和使用线程池,这就导致线程一直不会结束,一直存在内存泄漏!
        tl2 = null;
        System.gc();

        TimeUnit.SECONDS.sleep(1);

        System.out.println(tl1.get() + "   " + tl2.get());

        //为了避免内存泄漏,我们在使用完ThreadLocal之后,应该手动调用remove方法
        tl1.remove();
        tl2.remove();

        简而言之,如果一个ThreadLocal调用以上三种方法,都会在底层做个检查。如果当前ThreadLocal已经为null,就会去ThreadLocalMap中把对应的value赋值为null,等垃圾回收的时候就会自动释放内存了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值