Java中八个潜在的内存泄露风险,你知道几个?

Java后端技术

共 10414字,需浏览 21分钟

 ·

2021-03-29 06:58

往期热门文章:

1、往期精选优秀博文都在这里了!
2、一个牛逼的 多级缓存 实现方案!
3、分库分表?如何做到永不迁移数据和避免热点?
436 张图梳理 Intellij IDEA 常用设置,写代码贼爽!
52020年国内互联网公司的薪酬排名!

虽然Java程序员不用像C/C++程序员那样时刻关注内存的使用情况,JVM会帮我们处理好这些,但并不是说有了GC就可以高枕无忧,内存泄露相关的问题一般在测试的时候很难发现,一旦上线流量起来可能马上就是一个诡异的线上故障。

1. 内存泄露的定义

如果GC无法回收内存中不再使用的对象,则定义为内存有泄露

2. 未关闭的资源类

当我们在程序中打开一个新的流或者是新建一个网络连接的时候,JVM都会为这些资源类分配内存做缓存,常见的资源类有网络连接,数据库连接以及IO流。值得注意的是,如果在业务处理中异常,则有可能导致程序不能执行关闭资源类的代码,因此最好按照下面的做法处理资源类
public void handleResource() {
    try {
        // open connection
        // handle business
    } catch (Throwable t) {
        // log stack
    } finally {
        // close connection
    }
}

3. 未正确实现equals()hashCode()

假如有下面的这个类
public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
}
并且如果在程序中有下面的操作
@Test
public void givenMapWhenEqualsAndHashCodeNotOverriddenThenMemoryLeak() {
    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类没有实现equals方法,因此使用Objectequals方法,直接比较实体对象的地址,所以map.size() == 100
如果我们改写Person类的代码如下所示:
public class Person {
    public String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == thisreturn 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;
    }
}
则上文中的单元测试就可以顺利通过了,需要注意的是这个场景比较隐蔽,一定要在平时的代码中注意。

4. 非静态内部类

要知道,所有的非静态类别类都持有外部类的引用,因此某些情况如果引用内部类可能延长外部类的生命周期,甚至持续到进程结束都不能回收外部类的空间,这类内存溢出一般在Android程序中比较多,只要MyAsyncTask处于运行状态MainActivity的内存就释放不了,很多时候安卓开发者这样做只是为了在内部类中拿到外部类的属性,殊不知,此时内存已经泄露了。
public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        new MyAsyncTask().execute();
    }

    private class MyAsyncTask extends AsyncTask {
        @Override
        protected Object doInBackground(Object[] params) {
            return doSomeStuff();
        }
        private Object doSomeStuff() {
            //do something to get result
            return new MyObject();
        }
    }
}

5. 重写了finalize()的类

如果运行下面的这个例子,则最终程序会因为OOM的原因崩溃
public class Finalizer {
    @Override
    protected void finalize() throws Throwable {
    while (true) {
           Thread.yield();
      }
  }

public static void main(String str[]) {
  while (true) {
        for (int i = 0; i < 100000; i++) {
            Finalizer force = new Finalizer();
        }
   }
 }
}

JVM对重写了finalize()的类的处理稍微不同,首先会针对这个类创建一个java.lang.ref.Finalizer类,并让java.lang.ref.Finalizer持有这个类的引用,在上文中的例子中,因为Finalizer类的引用被java.lang.ref.Finalizer持有,所以他的实例并不能被Young GC清理,反而会转入到老年代。在老年代中,JVM GC的时候会发现Finalizer类只被java.lang.ref.Finalizer引用,因此将其标记为可GC状态,并放入到java.lang.ref.Finalizer.ReferenceQueue这个队列中。等到所有的Finalizer类都加到队列之后,JVM会起一个后台线程去清理java.lang.ref.Finalizer.ReferenceQueue中的对象,之后这个后台线程就专门负责清理java.lang.ref.Finalizer.ReferenceQueue中的对象了。这个设计看起来是没什么问题的,但其实有个坑,那就是负责清理java.lang.ref.Finalizer.ReferenceQueue的后台线程优先级是比较低的,并且系统没有提供可以调节这个线程优先级的接口或者配置。因此当我们在使用使用重写finalize()方法的对象时,千万不要瞬间产生大量的对象,要时刻谨记,JVM对此类对象的处理有特殊逻辑。

6. 针对长字符串调用String.intern()

如果提前在src/test/resources/large.txt中写入大量字符串,并且在Java 1.6及以下的版本运行下面程序,也将得到一个OOM
@Test
public void givenLengthString_whenIntern_thenOutOfMemory()
  throws IOException, InterruptedException 
{
    String str 
      = new Scanner(new File("src/test/resources/large.txt"), "UTF-8")
      .useDelimiter("\\A").next();
    str.intern();
    
    System.gc(); 
    Thread.sleep(15000);
}
原因是在Java 1.6及以下,字符串常量池是处于JVM的PermGen区的,并且在程序运行期间不会GC,因此产生了OOM。在Java 1.7以及之后字符串常量池转移到了HeapSpace此类问题也就无需再关注了

7. ThreadLocal的误用

ThreadLocal一定要列在Java内存泄露的榜首,总能在不知不觉中将内存泄露掉,一个常见的例子是:
@Test
public void testThreadLocalMemoryLeaks() {
    ThreadLocal<List<Integer>> localCache = new ThreadLocal<>();
   List<Integer> cacheInstance = new ArrayList<>(10000);
    localCache.set(cacheInstance);
    localCache = new ThreadLocal<>();
}
localCache的值被重置之后cacheInstanceThreadLocalMap中的value引用,无法被GC,但是其keyThreadLocal实例的引用是一个弱引用,本来ThreadLocal的实例被localCacheThreadLocalMapkey同时引用,但是当localCache的引用被重置之后,则ThreadLocal的实例只有ThreadLocalMapkey这样一个弱引用了,此时这个实例在GC的时候能够被清理。
img
其实看过ThreadLocal源码的同学会知道,ThreadLocal本身对于keynullEntity有自清理的过程,但是这个过程是依赖于后续对ThreadLocal的继续使用,假如上面的这段代码是处于一个秒杀场景下,会有一个瞬间的流量峰值,这个流量峰值也会将集群的内存打到高位(或者运气不好的话直接将集群内存打满导致故障),后面由于峰值流量已过,对ThreadLocal的调用也下降,会使得ThreadLocal的自清理能力下降,造成内存泄露。ThreadLocal的自清理实现是锦上添花,千万不要指望它雪中送碳。

8. 类的静态变量

Tomcat对在网络容器中使用ThreadLocal引起的内存泄露做了一个总结,详见:https://cwiki.apache.org/confluence/display/tomcat/MemoryLeakProtection,这里我们列举其中的一个例子。
熟悉Tomcat的同学知道,Tomcat中的web应用由webapp classloader这个类加载器的,并且webapp classloader是破坏双亲委派机制实现的,即所有的web应用先由webapp classloader加载,这样的好处就是可以让同一个容器中的web应用以及依赖隔离。
下面我们看具体的内存泄露的例子:
public class MyCounter {
 private int count = 0;

 public void increment() {
  count++;
 }

 public int getCount() {
  return count;
 }
}

public class MyThreadLocal extends ThreadLocal<MyCounter{
}

public class LeakingServlet extends HttpServlet {
 private static MyThreadLocal myThreadLocal = new MyThreadLocal();

 protected void doGet(HttpServletRequest request,
   HttpServletResponse response)
 throws ServletException, IOException 
{

  MyCounter counter = myThreadLocal.get();
  if (counter == null) {
   counter = new MyCounter();
   myThreadLocal.set(counter);
  }

  response.getWriter().println(
    "The current thread served this servlet " + counter.getCount()
      + " times");
  counter.increment();
 }
}
需要注意这个例子中的两个非常关键的点:
  • MyCounter以及MyThreadLocal必须放到web应用的路径中,保被webapp classloader加载
  • ThreadLocal类一定得是ThreadLocal的继承类,比如例子中的MyThreadLocal,因为ThreadLocal本来被common classloader加载,其生命周期与tomcat容器一致。ThreadLocal的继承类包括比较常见的NamedThreadLocal,注意不要踩坑。
假如LeakingServlet所在的web应用启动,MyThreadLocal类也会被webapp classloader加载,如果此时web应用下线,而线程的生命周期未结束(比如为LeakingServlet提供服务的线程是一个线程池中的线程),那会导致myThreadLocal的实例仍然被这个线程引用,而不能被GC,期初看来这个带来的问题也不大,因为myThreadLocal所引用的对象占用的内存空间不太多,问题在于myThreadLocal间接持有加载web应用的webapp classloader的引用(通过myThreadLocal.getClass().getClassLoader()可以引用到),而加载web应用的webapp classloader有持有它加载的所有类的引用,这就引起了classloader泄露,它泄露的内存就非常可观了。
参考文献:
  1. https://www.baeldung.com/java-memory-leaks
  2. https://cwiki.apache.org/confluence/display/tomcat/MemoryLeakProtection

往期热门文章:

1、历史文章分类导读列表!精选优秀博文都在这里了!》

2一个牛逼的 多级缓存 实现方案!
3、阿里一面:如何保障消息100%投递成功、消息幂等性?
4、GitHub 热榜:被网友疯狂恶搞的「蚂蚁呀嘿」项目终于开源了!
5、记住!看小电影前一定要检查一下域名是不是 HTTPS 的,不然....
6、拿到年终奖后马上辞职,厚道吗?
7、Redis 内存满了怎么办?
8、在 IDE 中玩转 GitHub
9、死磕18个Java8日期处理,工作必用!
10、把我坑惨的一个MySQL双引号!

浏览 26
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报