JMM只是一种概念,定义了多线程下如何访问内存。
程序应当保证以下特性
- 可见性
- 原子性
- 有序性
- StackOverflowError
如果线程请求的栈深度
大于虚拟机所允许的深度,将抛出StackOverflowError异常 - OutOfMemoryError
如果虚拟机动态扩展无法申请到足够的内存,就会抛出OutOfMemoryError异常
JAVA堆
对大多应用而言,Java对是Java虚拟机所管理的内存的最大的一块。Java堆是被所有线程共享的一块内存区域。在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,继续所有的对象实例都在这里分配内存。这一点Java虚拟机规范的描述是:所有的对象实例和数组都要在对上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上逐渐变得不是那么的绝对了。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作GC堆(Garbage Collection Heap)。现在收集器基本都采用分代收集算法。所以Java堆中还可以细分为:新生代和老年代。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。无论如何划分,都与存放的内容无关,其存放的都是对象实例。进一步划分的目的是更好地回收内存,更快地分配内存。
根据Java虚拟机的规范,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现上,既可以是固定大小的,也可以是可扩展的,不过当前的虚拟机都是按照可扩展来实现的(通过-Xms和-Xms控制)。如果在队中没有内存完成实例分配,并且堆也无法再扩展时,就会抛出OutOfMemoryError异常。
方法区(非堆)
方法区与堆一样,是各个线程的共享内存区域,它用于存储已被虚拟机加载的类信息(Class)、常量、静态变量(static)、即时编译器编译后的代码等数据。
运行时常量池
一般来说,运行时常量池除了保存Class文件中描述的符号引用
外,还会把翻译过来的直接引用也存储在运行时常量池中
。
运行时常量池相对于Class文件常量池的另一个重要特征就是具备动态性,Java语言并不是要求常量一定只有编译期才能产生,也就是并非预植入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中
,这种特性被开发人员利用得比较多的便是String类的intern方法。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,他可以使用Native函数直接分配堆外内存
,然后通过一个存储在Java堆中的DirectByteBuffer
对象作为这块内存的引用进行操作。这样避免了在Java堆和Native堆中来回复制数据。
HotSpot虚拟机
对象创建
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java对中划分出来。分局内存是否规整,其分配方式主要有以下的两种:
- 指针碰撞
- 指针碰撞的分配方式使用于Java堆中的内存时绝对规整的。
- 所有用过的内存都放在一边,空闲的放在另一边,中间存放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表
- 如果Java堆中的内存不是规整的,虚拟机就需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法那的收集器时,通常采用空闲列表。
除了如何划分可用空间外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是修改一个指针指向的位置,在并发情况下并不是线程安全的,可能出现正在给A对象分配内存,指针还买来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题的方案有两种:
- 对分配内存空间的动作进行同步处理-实际上虚拟机
采用CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为
本地线程分配缓冲
(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定
。虚拟机是否使用TLAB,可以通过-XX:-UseTLAB
参数来设定。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值
,如果使用TLAB,这一工作过程可以提前到TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型所对应的的零值。
在上面工作完成后,从虚拟机的角度看,一个新的对象已经产生了,但从Java程序的角度看,对象创建才刚刚开始,<init>
方法还没有执行,所有的字段都还为零值
。所以,一般来说,执行new指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成产生出来。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以划分为3块区域:
- 对象头(Header)
- 实例数据(Instance data)
- 对齐填充(padding)
第一部分对象头包括两部分信息
- 用于
存储对象自身的运行时数据
- 哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 这部分数据官方称为“Mark Word”
- 类型指针
- 即
对象指向它的类元数据的指针
,虚拟机通过这个确定这个对象是哪个类的实例。 - 如果这个对象是一个Java 数组,那么对象头还需要还需要记录
数组长度
的数据。
- 即
第二部分实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,度需要记录下来,这部分的存储顺序会受到虚拟机分配策略参数(FieldAllocationStyle)和字段在Java源码中定义的顺序的影响。
第三部分填充部分并不是必须存在的,仅起着占位符的作用。由于HotSpot VM要求对象的实例大小是8字节的整数倍
,如果对象实例没有对齐时,就需要进行填充了。
对象的访问定位
Java程序需要借助栈上的reference数据来操作堆上的具体对象。reference类型在虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该如何通过何种方式去定位、访问堆中的对象的具体位置。目前主流的访问方式两种:
- 句柄
- reference中存储的就是对象的句柄地址
- 优点:对象被移动时(垃圾收集器移动对象很频是普遍行为),仅需要修改句柄中实例数据的地址,而reference不需要修改
- 直接指针
- reference中存储的就是对象的地址
- 优点:节省了一次指针定位的时间开销,速度更快
如果使用句柄池访问对象,堆中会分配一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的
实例数据与类型数据各自的具体地址信息
,如图2-2所示
通过直接指针访问方式reference中存储的就是对象的地址。图2-3所示
例子
class Mynum{
public int i = 1;
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Mynum mynum = new Mynum();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
mynum.i = 11; //虽然i已经修改,并将i写会到主内存,但是main线程不可见。
System.out.println("update i = 11");
},"thread1").start();
while(1 == mynum.i){//程序会一直在这个循环中
}
System.out.println(mynum.i);
}
}
线程间的通信需要通过主内存来完成。上面的程序虽然thread2修改了i的值并写回到了主内存,但此时main线程并不知道i值已经改变了,因为没有人通知main线程i的值已经被修改了。
将i变量加上volatile修饰后,一旦i的值修改,将通知所有线程将其工作内存的i的修改
class Mynum{
volatile public int i = 1;//加volatile修饰,实现可见性
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Mynum mynum = new Mynum();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
mynum.i = 11; //虽然i已经修改,并将i写会到主内存,但是对main线程不可见
System.out.println("update i = 11");
},"thread1").start();
while(1 == mynum.i){
}
System.out.println(mynum.i);
}
}
//update i = 11
//11