概述
对Java程序员来说,在虚拟机自动管理内存的情况下,不需要为每一个new出来的对象执行delete/free的操作,不容易出现内存泄漏和内存溢出问题。
运行时数据区域
Java虚拟机在执行java程序的过程中会把它管理的内存分成多个内存区域,每个内存区域的用途以及创建销毁时间都不同。
程序计数器
每个线程执行代码的时候都会有一个独立的程序计数器存储当前线程执行到了哪一行字节码。如果正在执行的是java方法时,存储的值为正在执行的虚拟机字节码指令的地址,如果是native方法时,这个计数器的值为空。jvm在执行多线程程序的时候,也是通过这个计数器去定位切换来的线程执行到了哪一行代码。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
Java虚拟机栈
Java虚拟机栈也是线程私有的,虚拟机栈描述的是Java方法执行的内存模型:每一个方法执行时,会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法执行完毕后出栈。
别人常说到的堆内存、栈内存,栈内存就是虚拟机栈,或者说是虚拟机栈的局部变量表部分。
局部变量表存储了编译期已知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,表示对象位置的数据)和returnAddress(指向了一条字节码指令的地址)。
在代码执行的过程中,如果线程请求的栈深度大于虚拟机栈,将抛出StackOverflowError异常。如果虚拟机栈可以动态拓展,但拓展的时候无法申请到足够的内存,将抛出OutOfMemoryError异常。
tips:对于方法值传递引用传递的理解:基本类型在压栈的时候会对数据进行拷贝,因此在方法内修改了基本数据类型的参数无法影响到外部。
本地方法栈
本地方法栈的功能与虚拟机栈类似,但本地方法栈为native方法服务。
Java堆
Java堆是虚拟机所管理的内存中最大的一块,它被所有的线程共享。该区的目的是存放对象的实例,几乎所有的对象实例都在这里分配内存,是虚拟机的垃圾回收器管理的主要区域。
Java堆的大小可以通过-Xmx和-Xms控制,如果堆中没有可用内存区分配新的实例了并且无法扩展了,那就会抛出一个OutOfMemoryError。
方法区
方法区也是被所有线程共享的内存区块,它主要存放类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。虚拟机对方法区的限制非常宽松,可以选择不实现垃圾收集(个人理解:第一点是因为该区所占用的数据少,第二点是很难去控制哪个类型的定义或者静态变量不需要使用了。)与堆同理,当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池是方法区的一部分,它主要用于存放编译期间生成的各种字面量和符号引用。
- 字面量:接近java语言层面的常量概念,如文本字符串、声明为final的常量值等。
- 符号引用:
- 类和接口的全限定名(org/fenixsoft/clazz/TestClass)
- 字段的名称和描述符(private int m;——> I m)
- 方法的名称和描述符(public int inc() ——> ()I inc);
直接内存
直接内存不属于虚拟机运行时区域,但是现在也经常被人用到。如NIO类就可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,减少了java堆和Native堆的来回复制,提高性能。
对象的创建
- 类检查:虚拟机遇到一条new指令时,首先会检查这个指令后的参数是否能在常量池中找到一个类的符号引用,并且检查这个符号代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行类的初始化过程。
- 分配内存:检查完毕后,虚拟机就要为新生的对象分配内存,对象所需的内存在类加载完毕之后就可以知道。为对象分配内存分有根据java堆内存是否规整分为两种方式:
- 指针碰撞:java堆规整,用过的内存的空闲的内存通过指针作为分界线,分配空间的时候将指针往空闲的空间挪动一段与对象大小相等的距离。
- 空闲列表:java堆不规整,通过一个列表记录哪些内存块是可用的,分配时找到一块足够大的空间分配给对象实例并更新列表上的记录。
Java堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定。但堆内存分配是一个非常频繁的行为,会出现并发的情况,因此需要考虑使用同步的功能,虚拟机采用的是CAS配上重试的方法保证更新操作的原子性。
- 初始化内存空间:内存分配的完成后,虚拟机需要将分配的内存空间都初始化为零(不包括对象头)。接下来设置对象的对象头:对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
- 执行构造函数初始化实例:执行<init>方法
对象的内存布局
在HotSpot虚拟机中,对象在内存中可以分为3块区域:对象头、实例数据和对齐填充。
对象头
-
对象自身的运行时数据:
- 哈希码:25位的对象标识Hash码,当对象被锁定时,该数据会被存储在线程的锁记录空间中(用于存储对象MarkWord的拷贝)。
- GC分代年龄:4位的java对象年龄,每经过一次gc未被回收掉,年龄加一。
- 锁状态标志:2位的锁状态标记位,为了尽可能少的二进制位表示更多的信息,该标志位的不同,整个Mark Word存储的内容就会不同,详见下面流程图中表格。
- 是否偏向锁标示:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
- 偏向线程ID;
- 偏向时间戳Epoch;
类型指针:通过这个指针确定实例属于哪个类。
-
拓展:
-
轻量级锁:“轻量级”是相对于使用操作系统互斥量来实现传统锁而言的,因此传统的锁机制就成为“重量级”锁。如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
- 加锁流程:如下图
- 解锁流程:如果对象的Mark Word指向线程的锁记录的话,那就用CAS操作把当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,那整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取该锁,那就要唤醒被挂起的线程。
-
偏向锁:如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。这个锁偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
-
加锁流程:假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,jdk1.6默认值),那么当锁对象第一次被线程获取的时候,虚拟将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外一个线程区尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后锁标志位恢复到未锁定(“01”)或轻量级锁定(“00”)的状态,后续同步的操作就如上面介绍的轻量级锁那样执行。
-
-
实例数据
实例数据无论是父类中定义的还是子类定义的内容都会记录起来。
对象的访问定位
现在虚拟机主流的对象数据访问方式有2种:
使用句柄
Java堆种会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体地址。
好处:reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收导致对象移动)只改变句柄中的实例指针而reference本身不需要改变。
直接指针(Sun HotSpot使用)
reference存储的直接就是对象地址。
好处:速度快,节省了一次指针定位的时间开销。