`HashMap`底层原理

引言

HashMap基于哈希表的Map接口的实现。此实现提供所有可选的映射操作,并允许使用null值和null键。(除了不同步和允许使用null之外,HashMap类与Hashtable大致相同)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap

Map map = Collections.synchronizedMap(new HashMap());

一、数据结构与冲突

HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过keyhashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的

图中,左边部分代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。右边部分则显示数组内部结构,HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。

二、HashMap相关属性

transient Entry[] table;//存储元素的实体数组

transient int size;//存放元素的个数

int threshold; //临界值,当实际大小超过临界值时,会进行扩容,扩容大小为当前的2倍。threshold = 负载因子*容量

final float loadFactor; //负载因子

transient int modCount;//被修改的次数

其中比较重要的两个参数是容量(Capacity) 和 负载因子(Load factor)

Initial capacity: The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
Load factor: The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.

简单的说,Capacity就是bucket的大小,loadFactor就是bucket填满程度的最大比例。

loadFactor越大,填满的元素越多,好处是,空间利用率高了,但冲突的机会加大了,链表长度会越来越长,查找效率降低。

反之,loadFactor越小,填满的元素越少,好处是冲突的机会减小了,但空间浪费多了,表中的数据将过于稀疏(很多空间还没用,就开始扩容了)

因此,必须在 “冲突的机会” 与 “空间利用率” 之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的 “时-空” 矛盾的平衡与折衷.

如果机器内存足够,并且想要提高查询速度的话可以将loadFactor设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将loadFactor设置大一点。不过一般取默认值0.75就好。

三、使用频率最高的两个方法putget

put函数大致的思路为:

1、对keyhashCode()hash,然后再计算index

2、如果没碰撞直接放到bucket里;

3、如果碰撞了,以链表的形式存在buckets后;

4、如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;

5、如果节点已经存在就替换old value(保证key的唯一性);

6、如果bucket满了(超过loadFactor * current capacity),就要resize

public V put(K key, V value) 
{
    // 对key的hashCode()做hash
    return putVal(hash(key), key, value, false, true);
}
 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 
{
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 计算index,并对null做处理
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    else 
    {
        Node<K,V> e; K k;

        // 节点存在
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

        // 该链为树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 该链为链表
        else 
        {
            for (int binCount = 0; ; ++binCount) 
            {
                if ((e = p.next) == null) 
                {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 写入
        if (e != null) 
        { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超过load factor*current capacity,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

get函数大致思路如下:

1、bucket里的第一个节点,直接命中;

2、如果有冲突,则通过key.equals(k)去查找对应的entry

3、若为树,则在树中通过key.equals(k)查找,O(logn)

4、若为链表,则在链表中通过key.equals(k)查找,O(n)

public V get(Object key) 
{
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
 
final Node<K,V> getNode(int hash, Object key) 
{
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) 
    {
        // 直接命中
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 未命中
        if ((e = first.next) != null) 
        {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在链表中get
            do 
            {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

四、resize的实现

put时,如果发现目前的bucket占用程度已经超过了loadFactor所希望的比例,那么就会发生resize。在resize的过程,简单的说就是把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中。resize的注释是这样描述的:

Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

大致意思就是说,当超过限制的时候会resize,然而又因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置

怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:

因此元素在重新计算hash之后,因为n变为2倍,那么n-1mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成原索引+oldCap。可以看看下图为16扩充为32的resize示意图:

这个设计非常巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

总结

通过hash的方法,通过putget存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过loadFactorresize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

2016/3/25 posted in  Java

虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并堆数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备和解析三个部分统称为连接。

虚拟机规范严格规定了有且只有四种情况必须立即对类进行初始化:

  • 遇到new、getstatic、putstatic和invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候,以及调用一个类的静态方法的时候
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化

194/412

2016/3/21 posted in  Java

类文件结构

Class类文件的结构

Class文件是一组以8位字节为基础单位的二进制流,每个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割若干个8位字节进行存储

Class文件格式只有两种数据类型:无符号数和表。

无符号数
  • u1u2u4u8来分别表示1个字节、2个字节、4个字节和8个字节的无符号数
  • 无符号数可以用来描述数字、索引引用、数量值,或者按照utf-8编码构成字符串值
  • 由多个无符号数或其他表作为数据项构成的复合数据类型
  • 所有表都习惯性地以_info结尾
  • 表用于描述有层次关系的复合结构的数据
  • 整个Class文件本质上就是一张表

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容器计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的集合。

魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。

Class文件的魔数为:0xCAFEBABECafe Babe

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version

根据表6-1:magicu4类型,因此是从offset0-offset3

常量池

紧接着主次版本号之后的是常量池入口,常量池是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一个u2类型的数据,代表常量池容量计数值(constant_pool_count)。这个容量计数是从1而不是0开始的。

常量池容量为十六进制的0x0016,即十进制的22,这就代表常量池中有21项常量

Class文件结构中只有常量池的容量计数是从1开始的,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都是从0开始。

常量池之中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  • 类和接口的全限定名(Fully Qualified Name
  • 字段的名称和描述符(Descriptor
  • 方法的名称和描述符

Java代码在进行javac编译的时候,是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法和字段的最终内存布局信息,因此这些字段和方法的符号引用不经过转换的话是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址之中。

常量池中的每一项常量都是一个表,共有11种结构各不相同的表结构数据,这11种表都有一个共同的特点,就是表开始的第一位是u1类型的标志位,代表当前这个常量属于哪种常量类型。

图6-3中常量池的第一个常量,它的标志位是0x07,再看表6-3会发现这个常量属于CONSTANT_Class_info类型,此类型的常量代表一个类或接口的符号引用。

CONSTANT_Class_info结构

tag是标志位

name_index是一个索引值,它指向常量池中的一个CONSTANT_Utf8_info类型的常量,此常量代表了这个类(或者接口)的全限定名,这里的name_index值(偏移地址:0x0000000B)为0x0002,即指向了常量池中的第二项常量。它的标志位(偏移地址:0x0000000D)是0x01,查看表6-3可知是一个CONSTANT_Utf8_info类型的常量,其结构为

length值说明这个字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。

本例中这个字符串的length值(偏移地址:0x0000000F)为0x001D,也就是长29个字节,内容为org/fenixsoft/clazz/TestClass

访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或接口层次的访问信息,包括:这个class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等等。

类索引、父类索引和接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。

类索引用于确定这个类的全限定名父类索引用于确定这个类的父类的全限定名。由于Java不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0.

接口索引结合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句后的接口顺序从左到右排列在接口的索引集合中

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串

对于接口索引集合,入口的第一项 - u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,那么该计数器值为0,后面接口的索引表不再占用任何字节。

字段表集合

字段表(field_info)用于描述接口或类中声明的变量。字段(field)包括了类级变量或实例级变量,但不包括在方法内部声明的变量。

各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

字段修饰符放在access_flags中

跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称及字段和方法的描述符。

全限定名org/fenixsoft/clazz/TestClass是这个类的全限定名,在使用时最后一般会加入一个;表示全限定名结束

简单名称:指没有类型和参数修饰的方法或字段名称,方法inc()和字段m的简单名称分别是incm

方法和字段的描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte, char, double, float, int, long, short, boolean)及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示

对于数组类型,每一纬度将使用一个前置的[字符来描述,如一个定义为java.lang.String[][]类型的二维数组,将被记录为:[[Ljava/lang/String;一个整型数组int[]将被记录为[I

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号()之内。

如方法void inc()的描述符为()V,方法java.lang.String toString()的描述符为()Ljava/lang/String,方法int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)的描述符为([CII[CIIII)I

  • 第一个u2类型的数据为容量计数器fields_count,其值为0x0001,说明这个类只有一个字段表数据。
  • 接下来是access_flags标志,其值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真,其他修饰符为假。
  • 代表字段名称的name_index的值为0x0005,从代码清单列出的常量表中可查到第五项常量是一个CONSTANT_Utf8_info类型的字符串,其值为m;代表字段描述符的descriptor_index的值为0x0006,指向常量池的字符串I
  • 根据这些信息,我们可以推断出原代码定义的字段为private int m

方法表集合

方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。

  • 第一个u2类型的数据(即是计数器容量)的值为0x0002,代表集合中有两个方法。
  • 第一个方法的访问标志值为0x001,也就是只有ACC_PUBLIC标志为真
  • 名称索引值为0x0007,查看代码清单的常量池可得方法名为<init>
  • 描述符索引值为0x0008,对应常量为()V
  • 属性表计数器attributes_count的值为0x0001就表示此方法的属性表集合有一项属性
  • 属性名称索引为0x0009,对应常量为Code,说明此属性是方法的字节码描述

Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还需要必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。

属性表集合

在Class文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要说明属性值所占用的位数长度即可。

Code 属性

Java程序方法体里面的代码经过javac编译器处理之后,最终变为字节码指令存储在Code属性内。

  • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为Code
  • attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共是6个字节,所以属性值的长度固定为整个属性表的长度减去6个字节
  • max_stack代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
  • max_locals代表了局部变量表所需的存储空间
2016/3/13 posted in  Java

垃圾收集器与内存分配策略

程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性。

Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的垃圾收集器所关注的是这部分内存

引用技术算法

虚拟机并不是通过引用计数算法来判断对象是否存活的

根搜索算法

通过一系列的名为CG Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用连(Reference Chain),当一个对象到GC Roots没有任何引用连相连时,则证明此对象是不可用的

再谈引用

  • 强引用:类似Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉引用的对象
  • 软引用:用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收
  • 弱引用:用来描述非必需对象,它的强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 虚引用:也称为幽灵引用或者幻影引用。它是最弱的一种引用关系。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知

生存还是死亡

在根搜索算法中不可达的对象,它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的,低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。稍后GC将对F-Queue中的对象进行第二次小规模的标记,只要对象能重新与引用链上的任何一个对象建立关联即可。

回收方法区

方法区(或者HotSpot虚拟机中的永久机)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象非常类似。例如一个字符串abc已经进入了常量池中,但是没有任何String对象引用常量池中的abc常量,也没有其他地方引用了这个字面量,如果在这个时候发生内存回收,而且必要的话,这个abc常量就会被系统清除出了常量池。

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻。类要同时满足下面三个条件才能算是“无用的类”:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

垃圾收集算法

标记 - 清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致:当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

这种算法的代价是将内存缩小为原来的一半。

新生代的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间每次使用Eden和其中的一块Survivor。当回收时,EdenSurvivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。

HotSpot虚拟机默认EdenSurvivor的大小比例是8:1,只有10%的内存会被浪费掉(备用Survivor),但是我们没办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion

标记 - 整理算法

根据老年代的特点,有人提出了另外一种“标记 - 整理”(Mark-Compact)算法,标记过程仍然与“标记-清楚”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

老年代中因为对象存活率高、没有额外空间对它进行担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

内存分配与回收策略

自动内存管理最终可以归结为自动化地解决了两个问题:

  • 给对象分配内存
  • 回收分配给对象的内存

对象的内存分配,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。

几条最普通的内存分配规则。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

新生态GC(Minor GC):指发生在新生代的垃圾收集动作,Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC / Full GC):指发生在老年代的GC。

大对象直接进入老年代

所谓大对象就是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

大对象对虚拟机的内存分配来说就是一个坏消息;比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一个Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认是15岁)时,就会被晋升到老年代。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代;如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。

2016/3/11 posted in  Java

自动内存管理机制

程序计数器

程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。

字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

由于Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个内核只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,每条线程之间的计数器互不影响,独立存储。我们称这类内存区域为“线程私有”的内存

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。

Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(指向了一条字节码指令的地址)

其中64位长度的longdouble类型的数据会占用两个局部变量空间(slot),其余的数据类型只占用1个。

局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在桢中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

Java

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”。

方法区(Method Area)

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性。Java不要求一定只能在编译器产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特征用得比较多的就是String类的intern()方法。

对象访问

Object obj = new Object();

如果这句代码出现在方法体中,那Object obj这部分的词义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。

new Object()这部分的语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值的结构化内存,根据具体类型以及虚拟机实现的对象内存布局的不同,这块内存的长度是不固定的。另外,在Java堆中还必须包含能查找此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。

  • 如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

使用句柄访问方式

  • 如果使用直接指针访问方式,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是地址

使用直接指针访问方式

  • 使用句柄访问方式的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改
  • 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多。
2016/3/10 posted in  Java