一个JedisConnectionException的异常跟踪调查

图片来自pixabay.com的spencerlikestorun会员

1. 问题缘起

有一个web服务应用一直在稳定运行,直到最近一个月,陆续三次在晚上出现了JedisConnectionException的异常,服务集群几乎在同一时间段都报同一错误,无法正常运行,重启后一切正常。

redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
   at redis.clients.util.Pool.getResource(Pool.java:50)
   at redis.clients.jedis.JedisPool.getResource(JedisPool.java:86)
    …
Caused by: java.util.NoSuchElementException: Timeout waiting for idle object
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:449)

2. 现场处理和初步调查结论

  • JedisConnectionException异常所报告的问题是,无法正常获取连接,通过查看redis连接池配置(maxTotal=200)和当时的并发流量(并发数小于5),没有发现不对和特别的地方。
  • 通过netstat查看网络TCP连接,redis连接数也不多(小于20),正常连接中,在redis服务器端可以观察到有连接正在陆续正常地新建和关闭,网络没有问题。
  • 查看zabbix和pinpoint监控,主站应用的CPU使用率有升高,不过在正常范围内,但响应时间有飙高(长达6秒),预计是获取redis连接阻塞的原因(在redis客户端设置阻塞时间为6秒)。
  • 为了能够保证主站恢复使用,调查一段时间后,决定重启应用,然后检查所有服务,一切恢复正常使用。

3. 调查方向

根据现象,确定如下几个调查方向,

  1. 查看redis的访问代码实现,是否有redis连接泄漏?并尝试是否可以本地开发环境重现同样问题。
  2. 查看线上配置,是否配置不正确或者未加载的情况?
  3. 查看网络原因,是否由于因为防火墙导致单机连接有限制?
    • 查看iptables
    • 使用tcpdump查看TCP流量
  4. 查看主站应用的请求访问日志和运行日志,查看异常前后有无特别的请求访问?
    • 请求访问日志 localhost_access_log.2018-12-28
    • 运行日志 catalina.out-20181228

4. 定位问题

通过查看代码时,发现有段代码在获取redis连接后,没有在方法退出时执行close()方法,

public void leakRedisConn() {
  Jedis jedis = null;
    try {
        jedis = jedisPool.getResource();
        jedis.ping();
    } catch (Exception ignored) {
    } finally {
        // 没有归还redis连接到空闲资源池
        // if (jedis != null) jedis.close();
    }
}

查看服务集群的请求访问日志和运行日志,发现在JedisConnectionException异常出现前,都有对该方法的调用,印证此方法和异常的出现有强关联性,因此上述方法应该就是异常发生的罪魁祸首。

该方法在获取连接后,并没有执行close()方法,使得redis连接无法回收到空闲资源池,每一次调用,都会泄漏一个连接,直到整个连接池队列达到上限,从而出现异常。具体原因见对JedisPool的代码分析。

5. 异常出现的原因和JedisPool代码分析

通过分析JedisPool的实现代码,可以看到里面有两个内部对象allObjects和idleObjects,一个是保存着所有连接对象,一个是保存着所有空闲连接对象,

public class JedisPool extends Pool<Jedis> {
  protected GenericObjectPool<T> internalPool;
}

// JedisPool里通过GenericObjectPool维护着所创建的连接池对象,实现Jedis连接的创建、获取和回收,
class GenericObjectPool{
    private final Map<T, PooledObject<T>> allObjects; // 保存着所有连接对象,上限通过maxTotal控制
    private final LinkedBlockingDeque<PooledObject<T>> idleObjects; // 保存着所有空闲连接对象,上限通过maxIdle控制
}

每次创建时,新建的连接会放入allObjects列表中,在close()方法调用时,空闲的连接会存放入idleObjects,以待后续重复利用。

方法jedisPool.getResource()会先尝试从空闲队列中获取连接,若获取不到,则尝试新建一个连接。一旦获取到的连接没有执行close()方法,则无法被回收到空闲队列,于是每一次方法调用,allObjects连接池中会增加一个,一旦数量超过maxTotal,则不再允许创建,请见下面的代码,

class GenericObjectPool{

  public T borrowObject(long borrowMaxWaitMillis) throws Exception {
     ...
     boolean create;
     p = (PooledObject)this.idleObjects.pollFirst();
     if(p == null) {
         p = this.create();
         if(p != null) {
            create = true;
         }
     }
     ...
  }

  private PooledObject<T> create() throws Exception {
        int localMaxTotal = this.getMaxTotal();
        long newCreateCount = this.createCount.incrementAndGet();

        // 当创建的个数大于maxTotal时,则不再允许创建,直接返回null
        if((localMaxTotal <= -1 || newCreateCount <= (long)localMaxTotal) && newCreateCount <= 2147483647L) {
            PooledObject p;
            try {
                p = this.factory.makeObject();
            } catch (Exception var6) {
            }
            ...
            return p;
        } else {
            this.createCount.decrementAndGet();
            return null;
        }
  }

}

注意的是,泄漏的连接在redis服务器端在超时后会被关闭,但在客户端则一直占着位置不会被释放,从而报JedisConnectionException异常,直到重启。

5. 后记

对这个JedisConnectionException的异常泄漏问题解决后,后来想了想,还有两个事情可以总结下,以待未来改进。

5.1 保留问题第一现场

在发生问题时,可以通过如下jmap命令保存下Java内存快照,保留第一现场,这样能够为后续查看发生问题,分析JedisPool对象的内部数据提供方便。

jmap -dump:format=b,file=jmap.dump <pid>

下图是通过Eclipse的MemoryAnalyzer工具分析的JedisPool对象,

jedis连接池

若当时有上面的内存快照,可以看到minIdle/maxIdle的配置情况,也可以看到createCount的变量值,这样会更加容易发现redis泄漏问题。

5.2 一颗老鼠屎,坏了一锅粥

对于Jedis连接池这种全局有限资源,必须严控管理,规范使用,不然一个地方出现泄漏,就会导致整个应用崩溃,本案例是一个活生生的例子。

从类状态看Java多线程安全并发

图片来自pixabay.com的razvansubscribe-316951会员

对于Java开发人员来说,i++的并发不安全是人所共知,但是它真的有那么不安全么?

在开发Java代码时,如何能够避免多线程并发出现的安全问题?这是所有Java程序员都会面临的问题。本文讲述了在开发Java代码时安全并发设计所需要考虑的点,文中以一张图展开,围绕着Java类状态,讨论各种情况下的并发安全问题。当理解了Java类的各种变量状态在并发情况下的表现,在写Java多线程代码时就可以做到心中有数,游刃有余,写出更加安全、健壮、灵活的多线程并发代码。

1. 多线程并发简介

在现代操作系统中,CPU的调度都是以线程为基本单位,各个线程各自独立获取到CPU时间片并执行代码指令,这就是多线程并发。于此同时,同一进程中的所有线程将共享当前进程的内存地址空间,这些线程可以访问当前内存地址空间上的同一个变量。若一个线程在使用某个变量时,另一个线程对这个变量进行修改,将造成不可预测的结果,这也是多线程并发问题。

一个简单的例子是,当一个线程循环读取一个数组时,另外一个线程对这个数组内对象进行删除,则前面一个线程可能读取失败或读取的是脏数据。

在多线程并发中,若一段代码的执行不能按预期正确地进行,或者执行的最终结果不可预测,则我们说这段代码并发不安全。换句话说,若线程之间能够按照预期执行代码,操作数据并获取到期望的结果,则实现了安全的并发。

2. 从类状态看Java安全并发

类状态是指类中所声明的变量,无论是公有变量、私有变量,亦或static和final修饰的变量,都是不同形式的类状态。按照Java语法,类变量有如下各种形式,

  • 公有变量(public)、私有变量(private)、保护变量(protect)
  • 静态变量(static)
  • 不可变变量(final)
  • 外部变量、内部变量、局部变量

这些类变量在运行时刻,映射到JVM内存中各种对象。Java安全并发设计,其核心在于如何处理这些变量在并发中的表现,掌握它们的特性是Java安全并发设计的关键。

下图从类状态出发,简要的说明了Java类变量的各种状态形式,及其相关的并发安全性,

Java安全并发设计

其中,

  • 绿色方块说明多线程并发安全。
  • 桔红色方块说明多线程并发不安全,会出现问题。
  • 图中的Java类是指完全依据面向对象设计,即:类成员变量被声明为私有,类方法只对类内部成员变量进行操作。
  • 有状态是指Java类中有成员变量声明,无论是公有、私有还是保护变量,亦或static和final;无状态则指类中无任何成员变量声明。
  • 私有状态是指类成员变量通过ThreadLocal进行了线程隔离,实现了按线程进行变量的分配;而共享状态则指类变量可以被多线程访问。
  • 不可变状态是指类成员变量被声明为final,是一种常量状态。
  • 静态状态是指类成员变量被声明为static。
  • 阻塞是指线程在执行代码前,必须获取锁,这个锁只有一个,通过锁实现了代码的多线程串行执行。

需要注意的是,该图是以Java语言为例来说明如何设计并发安全的对象类,但实践中,图中所涉及的状态、私有状态、不可变状态、非阻塞和阻塞访问,这些概念也应该适用于更多面对对象的编程语言。

下面将对上图中各个类状态进行一一讲解,介绍各个状态下并发设计的要点。

3. Java安全并发分解

3.1 无状态类

一个无状态类是指其没有任何声明的成员变量,例如,

public class StatelessClass {

    public void increment() {
        int i = 0;
        String msg = String.valueOf(i++);
        log.info(msg);
    }

}

无状态类是线程安全的。上述类中的increment()方法中,有两个本地变量i和msg,这两个本地变量都在方法栈空间上分配,由于栈内存空间是按线程各自独立的,相互隔离,因此栈空间上的变量是线程安全的。

由此还可以知道,在方法调用中分配的变量和对象,若在栈退出后变量或对象引用被JVM释放(不会被外部再访问到),则这个变量和对象也是线程安全的。关于本地变量和JVM栈空间的更多介绍,可以参考这篇文章

3.2 有状态类

和无状态相反,有状态类是指类中有声明的成员变量,例如

public class StatefulClass {

    private int i=0;

    public void increment() {
        i++;
    }

}

上面的类声明了一个int i的类变量,并初始化为0。大多数情况下Java类都是属于有状态类。

有状态是导致线程不安全的必要条件,但它不是充分条件,请继续看下文。

3.3 私有状态类

若Java类的状态通过ThreadLocal等方法,使得状态被隔离在各个线程中,相互不干扰,例如,

public class PrivateStateClass {

    private ThreadLocal<Integer> i = new ThreadLocal<>();

    public void set(int i) {
        i.set(i);
    }

    public void increment() {
        Integer value = i.get();
        i.set(value + 1);
    }

}

上面的类声明了一个ThreadLocal i的变量,这个类状态按各个线程进行了隔离,为一种私有状态,在执行increment()方法时可以被多线程安全访问。

3.4 共享状态类

正常的Java成员变量是线程共享的,即多个线程通过Java类提供的类方法访问类对象时,类对象中的成员变量可以被共享访问到,这是大多数情况下的应用场景。

共享状态在多线程并发时,不一定就是不安全,其又可以分为常量状态和可变状态两种情况来讨论,请见下文。

3.5 不可变状态类(常量状态)

下面的Java类中,有一个Integer PI变量被声明为final,这说明这个变量是一个常量对象,初始化之后不再改变。

public class FinalStateClass {

    private final Integer PI = 3.14;

    public double calculate(double radius) {
        return PI*radius*radius;
    }

}

多线程访问上述的calculate()方法是线程安全的。

final声明使得变量变为常量状态,多线程在访问时不能更改状态,在一定程度上实现了只读,从而是线程安全的。

3.6 可变状态类

对于可变的共享状态,当多线程访问时,必然出现协同操作和同步问题,若代码设计不当,则很容易出现线程不安全问题。

对于可变共享状态的访问,是多线程并发设计时的考虑重点。为了实现线程安全,一般通过下面两种方法,

  • 非阻塞设计(多线程并行执行,通过算法实现线程安全)
  • 阻塞设计(加锁,使得多线程实现串行执行)

下面是这两种方法的简单比较,

非阻塞设计 阻塞设计
多线程执行 并行执行 串行执行
安全实现方法 通过算法设计 通过锁
吞吐性能
优点 无死锁,线程不会被阻塞挂起 通过锁可以实现可控的线程调度
缺点 算法实现复杂,在高度竞争情况下,吞吐性能会低于锁 线程的挂起和上下文切换、死锁

更详细的讨论见下文。

3.7 非阻塞设计

下面的Java类通过原子变量AtomicInteger实现非阻塞的自增算法。

public class AtomicStateClass {

    private AtomicInteger i = new AtomicInteger(0);

    public void increment() {
        i.incrementAndGet();
    }

}

可以看到increment()方法没有添加任何锁,但是它可以实现多线程的安全自增操作。AtomicInteger其原理是通过CAS算法,即compareAndSet()方法,先查看变量是否变化,若没有变化则设置值,若有变化,则重新尝试,在绝大数情况下,值的设置在第一次尝试就成功。

更多非阻塞算法设计,比如非阻塞的栈、非阻塞的链表插入操作,见这里

3.8 阻塞设计

阻塞是指通过锁来控制线程对类状态的访问,使得当前状态只能由一个线程访问,其它访问线程则挂起等待,一直等到锁被释放后,所有的等待线程竞争锁,获得下一次访问权。

锁的设计,使得线程各自之间实现同步,串行执行代码指令,避免了竞争状态。但是于此同时,它也带来了死锁的困扰。若两个线程之间相互持有对方需要的资源或锁,则进入死锁状态。

JVM在解决死锁上没有提供较好的办法机制,更多的是提供监控工具来查看。对于死锁问题,最终解决方案是依赖开发者实现的代码,增加更多的资源,减少锁的碰撞,实现锁的有序持有和不定时释放,都是避免死锁的有效方案。

3.8.1 资源死锁(resource deadlock)

资源死锁是一种广泛的死锁定义,简单例子是,一个打印任务需要获得打印机和文件对象,若一个线程获得了打印机,而另外一个线程获得了文件对象,相互都不释放获得的资源,则出现资源死锁情况。

增加更多的资源,是解决此类死锁的有效方案。

3.8.2 锁顺序死锁(lock-ordering deadlock)

下面是一个锁顺序死锁的演示代码,

public class LockOrderingDeadLock {

    public void transferMoney(Account from, Account to, Integer amount) {
        synchronized (from) {
            synchronized (to) {
                from.debit(amount);
                to.credit(amount);
            }
        }
    }

}

若同时启动两个线程,分别执行下面两个操作,

  • 线程1:transferMoney(accountA, accountB, 100)
  • 线程2:transferMoney(accountB, accountA, 100)

则很有可能出现死锁状态,因为线程1在握有accountA对象锁的同时,线程2也握有accountB的锁。下面是对transferMoney方法测试过程中,通过JConsole观察到的死锁情况,

死锁2
图1:pool-1-thread-5握有account@120f74e3的锁,等待account@3e9369b9的锁

死锁1
图2:pool-1-thread-8握有account@3e9369b9的锁,等待account@120f74e3的锁

解决办法之一,是实现锁的按序持有,即对于任何两个对象锁A和B,先进行排序(排序算法必须是稳定有序),无论是哪个线程,都必须按照锁的排序,依次获取,从而避免相互持有对方需要的锁。

3.8.3 状态公开

状态公开是指类成员变量被公开,在一定程度上破坏了面向对象设计的数据封装性。对类方法再好的阻塞设计,一旦状态被公开,其并发安全性都会功亏一篑。

见下面的例子,类中定义了一个personList的对象,方法insert()和iterate()通过synchronized进行了阻塞加锁,其只能运行一个线程进入类方法执行操作。

public class PublicStateClass {

    public ArrayList<String> personList = new ArrayList<>();

    public synchronized void insert(String person) {
        personList.add(person);
    }

    public synchronized void iterate() {
        Integer size = personList.size();
        for (int i = 0; i < size; i++) {
            System.out.println(personList.get(i));
        }
    }

}

但多线程访问insert()和iterate()方法时,并不一定线程安全,主要原因是personList被声明了公开对象,使得类之外的线程可以轻易地访问到personList变量,从而导致personList的状态不一致,在iterate整个person列表时,可能列表中的对象已被删除。

这是类状态公开导致的线程安全问题,究其原因,还要归结于没有做好类的面对对象设计,对外部没有隐藏好数据。

下面的getList方法返回也会导致同样的问题,

public class PublicStateClass {

    private ArrayList<String> personList = new ArrayList<>();

    public List getList() {
        return personList;
    }

}

对于这样的问题,推荐的做法是,成员变量声明为私有,在执行读操作时,对外克隆一份数据副本,从而保证类内部数据对象不被泄露,

public class PublicStateClass {

    private ArrayList<String> personList = new ArrayList<>();

    public List getList() {
        return (List) personList.clone();
    }

}

4. 类的静态状态

类的静态状态是指类中被static声明的成员变量,这个状态会在类初次加载时初始化,被所有的类对象所共享。Java程序员对这个static关键字应该不会陌生,其使用的场景还是非常广泛,比如一些常量数据,由于没有必要在每个Java对象中存储一份,为了节省内存空间,很多时候声明为static变量。

但static变量并发不安全,从面向对象设计来说,一旦变量声明为静态,则作用空间扩大到整个类域,若被声明为公共变量,则成为全局性的变量,static的变量声明大大破坏了类的状态封装。

为了使静态变量变得多线程并发安全,final声明是它的“咖啡伴侣”。在阿里巴巴的编码规范中,其中一条是,若是static成员变量,必须考虑是否为final。

5. 类外部状态和多线程安全并发

上文在讲并发设计时,都是针对类内部状态,即类内部成员变量被声明为私有,类方法只对类内部变量进行操作,这是一种简化的应用场景,针对的是依据完全面向对象设计的Java类。一种更常见的情况是,类方法需要对外部传入的对象进行操作。这个时候,类的并发设计则和外部状态息息相关。

例如,

public class StatelessClass {

    public void iterate(List<Person> personList) {
        Integer size = personList.size();
        for (int i = 0; i < size; i++) {
            System.out.println(personList.get(i));
        }
    }

}

上面的类是一个无状态类,里面没有任何声明的变量。但是iterate方法接受一个personList的列表对象,由外部传入,personList是一个外部状态。

外部状态类似上文中内部状态公开,无论在类方法上做如何的参数定义(使用ThreadLocal/final进行声明定义),做如何并发安全措施(加锁,使用非阻塞设计),类方法其对状态的操作都是不安全的。外部状态的安全性取决于外部的并发设计。

一个简单的处理方法,在调用类方法的地方,传入一个外部状态的副本,隔离内外部数据的关联性。

6. 小结

类状态的并发,本质上是内存共享数据对象的多线程访问问题。只有对代码中各个Java对象变量的状态特性掌握透彻,写起并发代码时将事倍功半。

下面的类中,整个hasPosition()方法被synchronized修饰,

public class UserLocator {

    private final Map<String, String> userLocations = new HashMap<>();

    public synchronized boolean hasPositioned(String name, String position) {
        String key = String.format("%s.location", name);
        String location = userLocations.get(key);
        return location != null && position.equals(location);
    }

}

但仔细查看可以知道外部变量name和position、内部变量key和location都是并发安全,只有userLocations这个变量存在并发风险,需要加锁保护。因此,将上面的方法进行如下调整,将减少锁的粒度,有效提高并发效率。

public class UserLocator {

    private final Map<String, String> userLocations = new HashMap<>();

    public boolean hasPositioned(String name, String position) {
        String key = String.format("%s.location", name);
        String location;
        synchronized (this) {
            location = userLocations.get(key);
        }
        return location != null && position.equals(location);
    }
}

由此可见,了解类中各个变量特性对写好并发安全代码的重要性。在这个基础上,优化锁的作用范围,减少锁的粒度,实现锁分段,都可以做到信手拈来,游刃有余。

关于类状态,说了这么多,最后给一个全文性总结:面向对象进行类设计,隐藏好数据,控制好类的状态,从严控制变量的访问范围,能private尽量private,能final尽量final,这些都将有助于提高代码的并发健壮性。

7. 演示代码

所有的演示代码在如下的代码仓库中,

8. 参考资料

  1. 《Java并发编程实战》 [美] Brian Goetz 等 著,童云兰 等 译,ISBN:9787111370048。
  2. IBM DeveloperWorks:非阻塞算法简介

JVM内存数据模型

图片来自pixabay.com的Gipfelsturm69-2191891会员

本文将对JVM内存数据模型进行介绍,并给出一个简单的Java应用程序,描述其内存分配过程。在编写代码中,只有对类、各个变量和Java对象做到心中有数,才能“下笔”(敲代码)如有神。

1. JVM内存数据模型

如下图所示,

JVM内存数据模型

根据JVM规范,在运行时刻JVM内存数据分为如下6种,

  1. PC Register 程序计数器: 一个JVM中支持多个线程的执行,每个线程拥有各自独立的程序计数器,程序计数器指向线程执行的当前方法地址。
  2. JVM Stacks 栈区:每个线程拥有各自独立的JVM栈,一个栈存储着frames列表,每个frame对应着一个方法调用,其保存着方法调用所使用的本地变量和Java对象的引用,方法返回的值和异常。Frame按照后入先出的原则,执行并返回调用结果。这个数据区会发生如下两种内存溢出错误,
    • StackOverflowError 栈超过允许的调用深度
    • OutofMemoryError 栈超过允许的可用内存大小
  3. Heap 堆区,这个区的数据被所有线程所共享,是类对象创建时分配内存的地方。这个区的内存被JVM管理,实现对象的自动回收,也就是GC。这个数据区发生如下的内存溢出错误,
    • OutofMemoryError创建的对象超过可分配的内存大小
  4. Method Area方法区:这个区的数据被所有线程所共享,里面加载着类的定义,包括常量池,变量和方法数据等。这个数据区发生如下的内存溢出错误,
    • OutofMemoryError加载的类超过可分配的内存大小
  5. Run-Time Contant Pool 常量池,一个类文件中所定义的常量,一般会存储在方法区中。
  6. Native Method Stacks原生方法栈,Java内核代码中含有很多对操作系统原生方法的调用,这里存储着对原生方法调用的信息。其只对Java内核代码有意义,对于Java程序员来说,可以忽略这个区。

2. 一个简单应用程序的JVM内存数据

下面以一个简单的Java程序,描述下JVM的内存分配过程。

public class Demo {

    private static String CONSTANT = "hello,world";

    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.print(CONSTANT);

        int i = 0;
        String s = String.valueOf(i);
        demo.print(s);
    }

    private String print(String s) {
        System.out.println(s);
        return s;
    }

}

上述程序定义了一个Demo的类,里面包含了一个main主程序,和一个print()方法调用。

整个程序在运行过程中,JVM将会执行如下动作,

  1. JVM根据启动参数,初始化各个内存区。
  2. JVM加载Demo类到方法区,加载各个变量和方法定义,加载常量定义,其中字符串常量从堆区分配。
  3. 启动一个main线程,执行main主程序,线程的执行进度记录在程序计数器中。同时,在栈区初始化当前线程的方法调用栈。
  4. 进入main()方法调用,创建frame1,初始化如下变量
    • String args 输入参数,引用指向堆区所创建的args对象
    • Demo demo 引用,指向堆区所创建的demo对象
    • int i = 0 分配一个整型i,初始化值为0
    • String s 引用,指向堆区所创建的s对象
  5. 进入print()方法调用,创建frame2
    • String s 输入参数,引用指向堆区已创建的s对象
    • 返回s,指向堆区的s对象。
  6. print()方法调用结束,栈回到frame1。
  7. main()方法调用结束,方法调用栈清空。
  8. main线程执行结束。

上述在内存的分配可以描述为下图,

一个简单的Java程序内存分配.PNG

3. 堆区和元空间

JVM Heap是最大的内存分配区域,所有的Java对象都从这里获得内存存储空间,这里也是JVM自动内存回收(GC)的地方。

要想了解GC的工作机制,首先需要了解堆区中Java对象按代进行存储的机制。整个堆区分为如下几个区域,

  1. Eden 伊甸园区:这里是创建对象时最先分配内存的地方,名副其实的创世区
  2. Survivor 存活区:在发生Young GC时,会将Eden区中大量不再使用的对象删除,留下来的放入Survivor区。注意的是,Survivor区一般有两个,每次YGC时,将会S0和S1交换着来保存存活下来的对象。也就是说,S0和S1总有一个是处于清空状态。
  3. Tenured年老代:在GC多次过后,有些对象存活时间比较长,将会移入到年老代。

至于对象的存活与否,如何回收,这个将涉及到对象引用计数的概念,以及各个GC算法实现,这里不再扩展。

下图描述了堆区的示意图,

JVM堆区

图中还有一个元空间(MetaSpace),在JDK7之前其是一个永久代(PermGen)的内存空间,里面存放类定义等数据。在JDK8之后,永久代被元空间取代,两者的区别之一在于空间地址,永久代位于JVM Heap Memory中,而元空间移到了native memory中,这里的native memory是相对于JVM里面的heap memory而言,是位于JVM所运行的内存空间。

一个查看Java进程的堆区内存使用情况,命令如下(请使用JDK8的jmap工具),

$ jmap -heap 14120
Attaching to process ID 14120, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.111-b14

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4208984064 (4014.0MB)
   NewSize                  = 88080384 (84.0MB)
   MaxNewSize               = 1402994688 (1338.0MB)
   OldSize                  = 176160768 (168.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 31981568 (30.5MB)
   used     = 21396720 (20.405502319335938MB)
   free     = 10584848 (10.094497680664062MB)
   66.90328629290471% used
From Space:
   capacity = 1048576 (1.0MB)
   used     = 524288 (0.5MB)
   free     = 524288 (0.5MB)
   50.0% used
To Space:
   capacity = 1048576 (1.0MB)
   used     = 0 (0.0MB)
   free     = 1048576 (1.0MB)
   0.0% used
PS Old Generation
   capacity = 176160768 (168.0MB)
   used     = 68010784 (64.86013793945312MB)
   free     = 108149984 (103.13986206054688MB)
   38.60722496396019% used

6179 interned Strings occupying 524264 bytes.

4. Java主要启动参数

在了解了JVM内存数据模型之后,下面就可以看看Java 的各种启动参数配置,来了解如何配置JVM的内存空间。

可以通过java -X命令获取java的启动参数列表,或者查看文档。

参数 描述 默认值
-server 服务器模式
-Xms 堆初始化容量
-Xmx 堆最大可分配容量 建议根据可用物理内存设置
-Xmn 年轻代堆初始化容量(且为最大容量) 建议不配置,根据NewRatio动态调整
-Xss 栈大小 320KB-1MB
-XX:MetaspaceSize 元空间初始化容量
-XX:MaxMetaspaceSize 元空间最大可分配容量
-XX:NewSize 同-Xmn
-XX:NewRatio 年老代和年轻代的容量比例 2
-XX:SurvivorRatio Eden和单个Survivor的容量比例 8
-XX:+UseAdaptiveSizePolicy 允许JVM动态调整年老代和年轻代的容量比例 enabled
-XX:+PrintGC 每次GC时输出相关信息 disabled
-XX:+PrintGCDateStamps GC日志中输出日期时间
-Xloggc:./gc.log GC日志文件位置
-XX:+HeapDumpOnOutOfMemoryError 在OOM时输出堆区内存情况 disabled
-XX:HeapDumpPath=path 输出堆区内存到指定文件
-XX:+UseSerialGC 串行GC disabled
-XX:+UseParallelGC 并行GC JDK8中服务器模式下默认GC选项
-XX:+UseG1GC G1 GC JDK9中服务器模式下默认GC选项

5. 参考资料

  1. 官方文档:The Java Virtual Machine Specification
  2. Java Heap Space vs Stack – Memory Allocation in Java
  3. IBM Developer Works - Understanding how the JVM uses native memory on Windows and Linux
  4. 官方文档:JDK tool - java
  5. JDK9:JEP 248: Make G1 the Default Garbage Collector