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
变量设置为null
,OuterClass
的实例就可以被垃圾回收了。