JVM总结

运行时数据区域

  • 定义:Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
  • 类型:程序计数器、虚拟机栈、本地方法栈、Java堆、方法区(运行时常量池)、直接内存

各个区域的作用

程序计数器

当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响,也无法干涉;JVM中的程序计数器实际上是一块很小的存储空间,小到几乎可以忽略不计。但是它也是运行最快的存储区域。这和CPU里的寄存器容量小、速度快道理类似。每个线程都有自己的程序计数器,其生命周期和线程的生命周期一致。任何时刻,线程都只能有一个方法在执行,即"当前方法",程序计数器会存储当前线程正在执行的Java方法的JVM指令地址。执行native方法时,则是未指定值(undefined)。流程控制,比如分支、循环、跳转,异常处理、线程恢复等都依赖程序计数器。字节码解释器在工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令。

虚拟机栈

线程私有,线程在运行时,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程;栈里面存放着各种基本数据类型和对象的引用,栈桢大小缺省为1M(-Xss),例如-Xss256k;

  • 局部变量表(Local Variables):
    用于存储方法参数、定义在方法体里的局部变量。由于局部变量表是建立在线程独有的虚拟机栈上,因此是线程私有数据,不存在线程安全问题。局部变量表中的变量只在当前方法中有效,方法结束后,随着方法栈帧的销毁,局部变量也随之销毁。​ 方法能够嵌套调用的次数由栈的大小决定。通常认为栈空间越大,方法能够嵌套调用的次数越多。对于一个方法而言,其参数和局部变量越多,就会使得局部变量表越大,栈帧就会越大,占用的栈空间就会越多,导致其能够嵌套调用的次数减少。​ 局部变量表中的变量也是垃圾回收时的重要根节点(GC Roots),即只要是被局部变量表中的变量直接或间接引用的对象不会被垃圾回收掉。

  • 操作数栈(Operand Stack)
    也称为表达式栈(Expression Stack),在方法执行过程中根据字节码指令往栈中写入数据或者读取数据,即入栈(push)和出栈(pop)操作。说白了代码执行过程中的计算逻辑在计算过程中会产生一些中间结果,这些中间结果就存在操作数栈中,同时也作为计算过程中变量的临时存储空间。我们说Java虚拟机的解释器引擎是基于栈的执行引擎,这里的栈就是指的操作数栈。

  • 动态链接(Dynamic Linking)
    每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。有了这个引用,当前方法的代码就能实现动态链接。之前我们提到,java源文件编译为字节码文件时,所有的变量及方法引用都是以符号引用的方式存在class文件常量池中。描述一个方法调用了其它的方法时,就是通过常量池中指向方法的符号引用(#数字)表示的。动态链接的作用就是将所有的符号引用转换为调用方法的直接引用。

  • 方法返回地址(Return Address)
    存放调用该方法的程序计数器的值。方法执行完毕退出后都应该返回到该方法被调用的位置。因此正常退出时,应该将调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。异常退出时,返回地址需要根据异常表来确定。

  • 一些附加信息:
    ​ 栈帧中还允许携带与JVM实现相关的一些附加信息,比如对程序调试提供支持的信息。

本地方法栈

本地方法栈保存的是native方法的信息,当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单地动态链接并直接调用native方法;

线程私有区:由程序计数器、虚拟机栈、本地方法栈组成,编译时确实所需内存大小,随着线程的产生和消亡,因此不需要过多考虑内存回收的问题。

Java堆是需要重点关注的一块区域,几乎所有对象都分配在这里,也是垃圾回收发生的主要区域。因为涉及到内存的分配 (new关键字,反射等)与回收(回收算法,收集器等) 。Java堆可以位于物理上不连续的空间中,但是逻辑上应视为连续的。堆空间在JVM启动时就被创建并且空间大小也随之确定。堆空间是被所有线程共享的,但是其内部还是可以划分一小部分作为线程私有的缓冲区(Thread Local Allocation Buffer,TLAB),每个线程一份。JVM规范中的描述,所有的类的实例(对象)以及数组都会在运行时分配到堆上。在一个Java方法(函数)结束时,堆中的对象不会立即被移除,而是在执行垃圾回收时才会被清除(栈上分配的对象除外)。(-Xms堆的最小值;-Xmx堆的最大值; -Xmn新生代的大小;-XX:NewSize新生代最小值;-XX:MaxNewSize新生代最大值),例如-Xmx256m

  • Book类的类型信息存储在方法区;book局部变量存储在虚拟机栈中,保存一个引用,指向对象在堆中的位置;Book对象存储在堆中,同时保存对象类型数据的指针并指向方法区。
    在这里插入图片描述

为什么要在堆空间进行分代呢?
实际生产实践中,人们发现不同的Java对象生命周期往往不同。有的对象生命周期比较短,这类对象的创建和消亡都非常迅速。有的对象生命周期很长,甚至在某些情况下还能与JVM的生命周期保持一致。因此针对不同特点的对象采取不同的内存分配方式和垃圾回收策略是必要的。通常生命周期比较短的、那些朝生夕死的对象会存在年轻代;而经历过多次垃圾回收仍然存活的对象则会晋级到老年代。如果没有分代,所有对象杂糅在一起,这样垃圾回收时就需要对堆的所有区域扫描以找到那些没有用的对象。如果大多数对象都是朝生夕死的话,那么通过分代,把这些具有共同特征的对象放到一起,这样GC时的效率就会提高,能够一下子腾出大量空间。

方法区

也叫永久区,占用的实际物理内存可以是不连续的,用于存储已经被虚拟机加载的类信息,比如类型信息、类变量、类的方法、字段、运行时常量池、JIT代码缓存(这个区域是用来存储即时编译后的代码。编译后的代码就是本地代码(硬件相关的),它是由JIT(Just In Time)编译器生成的。)、常量、静态变量等数据。
用永久代存储类信息、常量、静态变量等数据很容易遇到内存溢出的问题,然而对永久代进行调优是很困难的,1.8及以后版本,将方法区移到本地内存(堆)(native heap),叫做元空间,同时将元空间与堆的垃圾回收进行了隔离,避免永久代引发的Full GC和OOM等问题;
1.7及以前版本(-XX:PermSize初始值;- XX:MaxPermSize最大值;)。1.8及以后版本(-XX:MetaspaceSize初始值; - XX:MaxMetaspaceSize最大值,未设置默认值,理论上可以是物理机的最大可使用内存),如:-XX:MaxMetaspaceSize=3M

直接内存

不是虚拟机运行时数据区的部分,也不是java虚拟机规范中定义的内存区域。直接内存的好处是可以避免数据在内核空间缓冲区和用户空间缓冲区之间拷贝,因此读写性能优于Java堆内存。频繁读写的场景下可以优先使用直接内存。Java NIO对此做了支持。在java堆内可以用 directByteBuffer对象直接引用并操作;这块内存不受java堆大小限制,但受本机总内存的限制,可以通过MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常,如直接分配128M的直接内存:ByteBuffer bb = ByteBuffer.allocateDirect(128 * 1024 * 1024);

垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后Full GC,然后顺便地帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出异常时,在catch里通知JVM执行System.gc()。如果虚拟机设置了-XX:+DisableExplicitGC,那就只能抛出内存溢出异常了。

为什么1.8版本使用元空间代替了永久代呢?
sun公司的hotspotVM 与 JRockitVM(号称最快的JAVA虚拟机,没有永久代)合并

常量池

1.6版本,运行时常量池是方法区的一部分。1.7及以后版本,在堆内存中;

常量池(class constant pool)

即通常所说的常量池指的是字节码文件(class文件)常量池。class文件常量池可以看成是一张表,执行虚拟机指令时从这张常量表中找到要执行的类名、方法名、参数类型、字面量等信息。他是class文件的一部分。class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool)。用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References),如下图所示:

运行时常量池(runtime constant pool)

即方法区的常量池。Java源文件被编译成class文件之后,就会生成class常量池。那么运行时常量池又是在什么时候创建的呢?运行时常量池是方法区的一部分。字节码加载后,即类加载到内存后,JVM就会将class文件常量池中的内容放到运行时常量池中。因此运行时常量池也是每个类都维护一个。在类加载过程中的resolve阶段class文件常量池中的符号引用替换为直接引用。解析过程中会查询全局字符串池,保证运行时常量池中的字符串与全局字符串池中的一致。运行时常量池包含多种不同的常量,包括编译期间就确定的数值字面量,也包括运行期间才解析得到的方法或字段引用。运行时常量池相对于class文件常量池的差别在于其具备动态性。Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。比如可以使用String.intern()直接强制入池。JVM维护一个全局的字符串常量池存放字符串数据,jdk7之后字符串常量池放到了堆中;class文件常量池是class文件的一部分,存放了编译器生成的各种字面量和符号引用,是一个静态的概念;运行时常量池是在类加载到虚拟机后创建的,除存放class文件常量池里的内容外,还能动态添加内容,是一个动态的概念。

字符串常量池(string pool)

字符串类单独维护了一个字符串池。String pool也称为String literal pool,是用来存放String类型的字符串常量。在jdk6及之前,字符串常量池存放在永久代。从jdk7开始字符串常量池的位置调整到Java堆中,因为永久代的垃圾回收效率很低,只在full GC的时候才会触发。如果应用程序很大,代码中有大量的字符串被创建,且回收效率低,则会导致永久代内存不足,甚至导致OOM。放到堆空间能够及时回收。jdk8开始方法区的实现变成元空间(Metaspace),字符串常量池还是在堆中存储。字符串常量池是全局唯一的,即每个Java虚拟机中只有一份。

堆和栈

  • 功能:栈内存:线程调用的方法会打包成栈桢,一个栈桢至少要包含局部变量表,操作数栈和帧数据区方法,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、 float、double、boolean、char)以及对象的引用变量,变量出了作用域就会自动释放;堆内存:用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;
  • 线程独享还是共享:栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
    堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
  • 空间大小:栈的内存要远远小于堆内存,栈的深度是有限制的,可能发生StackOverFlowError问题。如果方法的参数越多,那么也会占用更多的栈内存,从而使栈的深度变浅。

Java类各部分的内存区域

在这里插入图片描述

  • 方法区:ObjectType类的类信息、静态变量age、常量sex、final static ObjectType objectType的引用
  • :new ObjectType()、private boolean isKing、private ObjectType instance(都是对象的一部分)
  • :main方法中的ObjectType obj的局部变量的引用、int i=1(八大基本数据类型)、public native int hashCode();产生的指针

对象的创建

  1. 虚拟机遇到一条new指令时,先执行相应的类加载过程。
  2. 接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
    如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
    如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
    选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
    除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
    解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
    另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
    TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
    TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。
  3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
  5. 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

TLAB

TLAB全称为Thread Local Allocation Buffer,即线程本地分配缓存区,是线程私有的分配区。我们知道堆区是所有线程共享的,如果对象的创建非常频繁,在并发环境下从堆区划分内存空间将会产生线程安全问题。为了避免这个问题就需要引入加锁等机制,这会影响内存分配速度。因此从Eden区划分出一个很小的区域作为线程私有的缓存区域(TLAB)。

如果设置了虚拟机参数 -XX:UseTLAB,则每当线程初始化时都会申请一块指定大小的内存,只给当前线程使用。这样在多线程同时分配内存时,各个线程都在自己的空间上分配,不存在竞争,能提高内存分配的吞吐量。TLAB的内存占用非常小,默认只有整个Eden空间的1%,可以使用-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比。

由于TLAB的空间很小,所以不适合存储大对象。一旦对象在TLAB上分配失败,JVM就会使用加锁机制保证操作的原子性,从而直接在Eden区分配内存。尽管不是所有对象都能够在TLAB上分配成功,但是JVM确实是将TLAB作为对象内存分配的首选。

对象的内存布局

在这里插入图片描述
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头
  1. 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
    在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么32Bit空间中的25bit用于存储对象哈希码,4bit存储对象分代年龄,2bit存储锁标志位,1bit固定为0。
  2. 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象头上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。
  3. 如果创建的是数组,还需要保存数组的长度。因为虚拟机可以通过普通java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
  • 实例数据
    这是真正存放对象有效信息的,包括程序中定义的各种类型的字段(包括从父类继承的)。
public class Order {
    String orderNo;
    Item item;
    public Order(String orderNo, Item item) {
        this.orderNo = orderNo;
        this.item = item;
    }

    public static void main(String[] args) {
        Order order = new Order("O10001", new Item("物品"));
    }
}

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

在这里插入图片描述

  • 对齐填充
    并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

空间效率分析

在HashMap<Long,Long>结构中,只有key和value所存放的两个长整型数据是有效数据,共16B(2×8B)。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的Mark Word、8B的Klass指针,在加8B存储数据的Long值。在这两个Long对象组成Map.Entry之后,又多了16B的对象头(8字节的整数倍,1倍或者2倍),然后一个8B的next字段和4B的int型的hash字段,为对齐还必须添加4B的空白填充(对象的大小必须是8字节的整数倍),最后还有HashMap中对这个Entry的8B的引用。这样增加两个长整型数字实际耗费的内存为
Long(24B)x2 + Entry(32b) + HashMap Ref(8b)= 88b,空间效率为16B/88B=18%

对象的访问定位

在这里插入图片描述

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。

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

如果使用直接指针访问, reference中存储的直接就是对象地址。

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问的。

判断对象是否存活

引用计数算法

有一个其他的引用指向这个对象,则这个对象的引用计数+1,出现一个问题就是循环引用时对象的引用计数都+1,需要一个线程去处理这种情况。
特点:快,方便,实现简单;缺点:对象相互引用时,很难判断对象是否该回收。

可达性分析(根可达)

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在这里插入图片描述

生存还是死亡

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

如果这个对象被判定为有必要执行finalize() 方法,那么这个对象将会放置在一个叫做f-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱那基本上它就真的被回收了。

各种引用

强引用 StrongReference

一般的Object obj = new Object() ,就属于强引用。当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

软引用 SoftReference

如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。垃圾收集线程会在虚拟机抛出OutOfMemoryError之前回收软引用对象,而且虚拟机会尽可能优先回收长时间闲置不用的软引用对象。对那些刚构建的或刚使用过的“较新的”软对象会被虚拟机尽可能保留,这就是引入引用队列ReferenceQueue的原因。
软引用对象是在jvm内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的。就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。

public class SoftReferenceTest {

    public static void main(String[] args) {
        User user = new User(1, "a");
        SoftReference<User> softReference = new SoftReference<>(user);
        //保证new User(1, "a")实例只有softReference软引用
        user = null;
        System.out.println(softReference.get());
        //gc时不一定回收,回收的条件必须是内存满了
        System.gc();
        System.out.println("GC之后");
        //user没有被回收
        System.out.println(softReference.get());
        List<Byte[]> list = new LinkedList<>();
        try {
            for (int i = 0; i < 100; i++) {
                System.out.println(softReference.get());
                list.add(new Byte[1 * 1024 * 1024]);
            }
        } catch (Throwable throwable) {
            //内存满了,发生oom后,user实例被回收了
            System.out.println("throwable......" + softReference.get());
        }
    }
}
弱引用 WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

public class WeakReferenceTest {

    public static void main(String[] args) {
        User user = new User(1, "a");
        WeakReference<User> weakReference = new WeakReference<>(user);
        user = null;
        System.out.println(weakReference.get());
        System.gc();
        System.out.println("gc之后");
        System.out.println(weakReference.get());
    }
}
虚引用 PhantomReference

虚引用最弱,被垃圾回收的时候收到一个通知。

注意:软引用 SoftReference和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。
例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

回收方法区

java虚拟机规范中不要求虚拟机在方法区实现垃圾收集,永久代(方法区)的垃圾收集效率很低。

永久代的垃圾收集主要回收两部分内容,废弃常量和无用的类。

  • 回收废弃常量与回收Java堆中的对象非常类似以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String 对象是叫做“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

  • 类需要同时满足下面3个条件才能算是“无用的类”
    该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
    加载该类的ClassLoader已经被回收。
    该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    虚拟机可以回收无用类(仅仅是可以)。是否对类进行回收,HotSpot 虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及-XX:+TraceClassLoading-XX:TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class-XX:+TraceClassLoading可以在Product 版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持
    在大量使用反射、动态代理、CGLib 等ByteCode 框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

对象分配的过程

为新的对象分配内存是一件极其复杂而又严谨的事儿。既要考虑内存的合理分配,还要考虑之后的内存回收。不同的垃圾收集器下可能存在差别。本文按照经典的流程来讲述。
在这里插入图片描述

  1. 首先,new出来的对象先会放到新生代的伊甸园区,此区有大小限制,后面会讲如何设置。
  2. 当伊甸园区满,再创建对象时,JVM垃圾收集器将对伊甸园区进行垃圾回收(Minor GC),将没有引用指向的的对象清除之后再放入新的对象到伊甸园区。
  3. 接着将伊甸园区中剩余的未被回收的对象移到幸存者0区。
  4. 下次再触发垃圾回收时,上次回收幸存下来放到Survivor 0区的,如果没有被回收,将会复制到幸存者1区。
  5. 如果再次经历垃圾回收,没有被回收的对象又会重新复制回幸存者0区。就这样一直往复。
  6. 如果来回往复达到设定的次数(默认的年龄计数为15次),则进入老年代。这个参数是:-XX:MaxTenuringThreshold=15
  7. 在老年代的对象,垃圾回收的频率远小于年轻代。当老年代内存不足时,再次触发垃圾回收(Major GC),清理垃圾对象。
  8. 如果在老年代已经执行了垃圾回收的前提下,空间依然不足以存放对象,则产生OOM异常。​ java.lang.OutOfMemoryError:Java heap space

回收策略

频繁收集新生代、较少收集老年代、几乎不动永久代/元空间。
不同年龄段的对象分配原则如下:

  1. 对象优先在Eden分配。如果说Eden内存空间不足,就会发生Minor GC。
  2. 大对象直接进入老年代。大对象就是需要大量连续内存空间的Java对象,比如很长的字符串和大型数组,会导致内存有空间但还是需要提前进行垃圾回收获取连续空间来放他们,在新生代中(标记-复制算法)会进行大量的内存复制。配置-XX:PretenureSizeThreshold参数 ,大于这个数量直接在老年代分配,缺省为0 ,表示绝不会直接分配在老年代。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存复制。
  3. 长期存活的对象将进入老年代。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,对象在Survivor区中每“熬过”一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
  4. 动态年龄判定。为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。假如一个Survivor空间占1M,那么某个年龄的对象大小总和大于512K,则进入老年代。
  5. 空间分配担保:新生代中有大量的对象存活,survivor空间不够,当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代(换句话说,Survivor能够容纳的对象还是在Survivor中,不会直接进入老年代)。在发生MinorGC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,或者历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次MinorGC,否则需要进行一次FullGC。
    在这里插入图片描述
    JVM内存分配测试用例

栈上分配,逃逸分析

虚拟机提供的一种优化技术,基本思想是,对于线程私有的对象,将它打散分配在栈上,而不分配在堆上。好处是对象跟着方法调用自行销毁,不需要进行垃圾回收,可以提高性能。逃逸分析的目的是判断对象的作用域是否会逃逸出方法体。 注意,任何可以在多个线程之间共享的对象,一定都属于逃逸对象
如果经过逃逸分析发现一个对象并没有逃逸出所在方法的话,那么可能就会被优化成栈上分配。即无需在堆上分配,也无需垃圾回收。
所谓逃逸分析就是指Java编译器能够分析出一个对象的引用的使用范围从而决定是否将该对象分配到栈上或是堆上。如果一个对象在方法中定义之后仅在方法内部使用,则认为没有发生逃逸;如果这个对象被外部方法所引用,则认为发生逃逸。例如作为返回参数返回到调用的地方,或者作为参数传递到其它地方。没有发生逃逸的对象可以分配到栈上,随着方法执行的结束,栈空间就释放。
比如下列几种情况都是没有发生逃逸的,对象的作用域都只在方法内部。

public void method1(){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("test");
        System.out.println(stringBuffer.toString());
}

public String method2(){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("test");
        return stringBuffer.toString();
}

逃逸的例子比如下面这些。对象被返回给方法之外的调用者、为成员属性赋值。

public StringBuffer method3(){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("test");
        return stringBuffer;
}

public void setValue(){
        this.stringBuffer = new StringBuffer();
    }

逃逸分析有其优点,但是也不能忽视其缺陷。逃逸分析自身也需进行一系列的复杂分析,也是一个耗时的过程。如果经过逃逸分析后发现没有一个对象不是逃逸的,那这个逃逸分析的过程就浪费掉了。

启用栈上分配

-server JVM运行的模式之一, server模式(Linux)才能进行逃逸分析, JVM运行的模式还有mix(混合模式)/client(Windows)
-Xmx10m和-Xms10m:堆的大小
-XX:+DoEscapeAnalysis:启用逃逸分析(1.6版本之后默认打开)
-XX:+PrintEscapeAnalysis:查看分析结果
-XX:+PrintGC:打印GC日志
-XX:+EliminateAllocations:标量替换(默认打开)1
-XX:+EliminateLocks:开启同步消除(synchronized锁对象在非逃逸情况下的消除优化)
-XX:+PrintEliminateAllocations:查看标量的替换情况
-XX:-UseTLAB 关闭本地线程分配缓冲,TLAB:ThreadLocalAllocBuffer
对栈上分配发生影响的参数就是三个,-server、-XX:+DoEscapeAnalysis和-XX:+EliminateAllocations,任何一个发生变化都不会发生栈上分配,因为启用逃逸分析和标量替换默认是打开的,所以JVM的参数只用-server一样可以有栈上替换的效果

堆空间的参数设置

官方文档

  • -Xms:初始堆空间内存大小,默认为物理内存的1/64,比如设置初始堆大小为100M,-Xms100m
  • -Xmx:最大堆内存大小,默认为物理内存的1/4,比如设置最大Java堆内存大小为1G,-Xmx1024m
  • -XX:NewSize:新生代的初始内存大小,比如-XX:NewSize=256m
  • -XX:MaxNewSize:新生代的最大内存大小,比如-XX:MaxNewSize=256m
  • -Xmn:设置新生代的大小,等价于 (-XX:NewSize = -XX:MaxNewSize),比如-XX:NewSize=256m和-Xmn256m都是设置新生代大小为256兆
  • -XX:NewRatio:设置新生代和老年代的堆空间大小比例,默认值是2,就是新生代和老年代比例是1:2,比如新生代老年代的比例是1:4,就是-XX:NewRatio=4
    新生代大小配置的优先级为:-XX:NewSize > -Xmn > -XX:NewRatio
  • -XX:SurvivorRatio:设置新生代中Eden区和S0、S1区的比例。默认值是8,就是8:1:1。比如设置Eden区和S0、S1区的比例为:4:1:1就是-XX:SurvivorRatio=4
  • -XX:MaxTenuringThreshold:设置新生代的年龄阈值,默认为15,比如设置为20,-XX:MaxTenuringThreshold=20
  • -XX:+PrintGCDetails:输出详细的GC日志,默认是关闭的

垃圾回收算法

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先标记(通过根可达算法分析后的两次标记完成)出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足有两个:
一是效率问题,标记和清除两个过程的效率都不高。
另一个是空间利用率,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法(Copying)

新生代算法。将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将可用内存缩小为了原来的一半。

标记-整理算法(Mark-Compact)

老年代算法。首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

执行引擎

前文已经讲述了JVM负责加载字节码文件到其内部,但是字节码本身并不能够直接运行在操作系统上,其内部只是一些能够被JVM所识别的字节码指令、符号表,以及其它辅助信息。
要让Java程序运行起来,必须依赖执行引擎,执行引擎的作用就是将字节码指令解释、编译为可以执行的本地机器指令。
解释器和编译器
解释器(Interpreter):根据Java虚拟机规范对字节码进行逐行解释执行,即将每条字节码指令转换为平台对应的本地机器指令执行。
编译器:在Java中指的是JIT编译器,即即时编译器(Just in time compiler),将源代码直接编译为平台对应的本地机器码。
作为业界流行的高性能虚拟机,Hotspot VM 采用解释器与即时编译器并存的架构。即在Java虚拟机运行时,解释器和即时编译器相互协作,取长补短。权衡编译代码的耗时和直接解释执行的耗时,最终做出最优的选择。这也是Java程序性能可以和C/C++一较高下的原因之一。
Hotspot VM的执行方式就像它的名字所代表的的含义一样。即当虚拟机启动时,解释器首先发挥作用,而不必等待即时编译器全部编译完成再执行。这样可以节省许多不必要的编译时间。随着程序的运行,即时编译器逐渐发挥作用,它会根据热点代码探测的结果,将那些"热点"代码编译为本地机器指令,提高程序的执行效率。热点代码可以认为是那些频繁被调用的代码。
默认情况下Hotspot VM采用解释器和编译器并存的方式。可以通过参数设置。
-Xint:完全采用解释器模式执行
-Xcomp:完全采用即时编译器模式执行,如果即时编译器出现问题解释器会介入
-Xmixed:采用解释器+即时编译器的模式
正因为引入了JIT,Java程序也存在"预热"一说。Java程序刚跑起来时,大部分都是解释执行的,一段时间后热点代码被编译,存到JIT代码缓存中,后续这部分程序的执行就是直接运行编译好的机器码,因此程序的整体运行性能逐步提升。


  1. 标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate ),Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。 ↩︎

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值