一个Java对象的创建过程就是类实例化的过程,往往会包括类初始化的过程,在第一次创建一个类的对象时,就需要对类进行初始化,以后再创建这个类的对象时,只需进行类的实例化,就不需要进行类的初始化了。
1、类初始化与类实例化的区别
类的生命周期包括:加载、连接(验证、准备、解析)、初始化、使用和销毁,类的初始化就是类生命周期中的一部分,在上面已经详细讲述,此处不再赘述。
特别需要指出的是,类的实例化与类的初始化是两个完全不同的概念:
类的实例化是指创建一个类的实例(对象)的过程,是为类中实例变量赋值的过程。
类的初始化是指为类中各个类变量(被static修饰的成员变量)赋初始值的过程,是类生命周期中的一个阶段。
在类初始化阶段,JVM才去执行程序中的代码,根据程序员编写的代码去初始化类变量(如果有类变量的话),或者更直接地说:类初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。静态语句块只能访问到定义在静态语句块之前的静态变量,定义在它之后的静态变量,在前面的静态语句块可以赋值,但是不能访问。如下:
public class Test{
static{
i=0;
System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前引用)
}
static int i=1;
}
类构造器<clinit>()与实例构造器<init>()不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器<clinit>()执行之前,父类的类构造器<clinit>()执行完毕。由于父类的构造器<clinit>()先执行,也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。特别地,类构造器<clinit>()对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成类构造器<clinit>()。
虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
在同一个类加载器下,一个类只会被初始化一次,但是一个类可以任意地实例化对象,即类可以被实例化多次。也就是说,在一个类的生命周期中,类构造器<clinit>()最多会被虚拟机调用一次,而实例构造器<init>()则会被虚拟机调用多次,只要程序员还在创建对象。(注意,这里所谓的实例构造器<init>()是指收集类中的所有实例变量的赋值动作、实例代码块和构造函数合并产生的。)
2、对象在内存中的分配
Java中创建一个对象,一定是在堆中分配内存空间吗?
栈中存放一些方法参数和局部变量,堆中主要存放对象,即通过new关键字创建的对象。实例变量和数组元素也是存放在堆内存中的。
在《深入理解Java虚拟机》一书中,关于Java堆内存有这样一段描述:
随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
JIT(Just-In-Time)编译器:(参考:深入浅出 JIT 编译器_jitkb-优快云博客
浅谈对JIT编译器的理解。 - stubbornnnnnnn - 博客园)
逃逸分析:
逃逸分析是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个对象引用的使用范围,从而决定是否要将这个对象分配到堆上。
在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他过程或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。
经过逃逸分析之后,可以得到三种对象的逃逸状态。
1、GlobalEscape(全局逃逸),即一个对象的引用逃出了方法或者线程。例如,一个对象的引用赋值给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
2、ArgEscape(参数级逃逸),即在方法调用过程当中传递对象的引用给一个方法。这种状态可以通过分析被调方法的二进制代码确定。
3、NoEscape(没有逃逸),一个可以进行标量替换的对象。可以不将这种对象分配在传统的堆上。
编译器可以使用逃逸分析的结果,对程序进行一下优化:
1、堆分配对象变成栈分配对象。一个方法当中的对象,对象的引用没有发生逃逸,那么这个对象可能会被分配在栈内存上而非常见的堆内存上。
2、消除同步。线程同步的代价是相当高的,同步的后果是降低并发性和性能。逃逸分析可以判断出某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作,这样就能大大提高并发程度和性能。
3、矢量替代。逃逸分析方法如果发现对象的内存存储结构不需要连续进行的话,就可以将对象的部分甚至全部都保存在CPU寄存器内,这样能大大提高访问速度。
下面看一个逃逸分析的例子:
class Main {
public static void main(String[] args) {
example();
}
public static void example() {
Foo foo = new Foo(); //alloc
Bar bar = new Bar(); //alloc
bar.setFoo(foo);
}
}
class Foo {}
class Bar {
private Foo foo;
public void setFoo(Foo foo) {
this.foo = foo;
}
}
在这个例子中有三个类Main、Foo和Bar,在Main中创建了两个对象:Foo和Bar对象,同时把Foo对象的引用赋值给了Bar对象的方法。此时,如果Bar对象在堆上就会引起Foo对象的逃逸(因为堆是线程共享区域)。但是,在本例当中,编译器通过逃逸分析,可以知道Bar对象没有逃出example()方法,因此这也意味着Foo也没有逃出example方法。因此,编译器可以将这两个对象分配到栈上。
在Java代码运行时,通过JVM参数DoEscapeAnalysis 可指定是否开启逃逸分析。
-XX:+DoEscapeAnalysis : 表示开启逃逸分析
-XX:-DoEscapeAnalysis : 表示关闭逃逸分析
从jdk 1.7开始已经默认开启逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis。
在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。开启了逃逸分析之后,新创建的对象可能在栈中分配内存,也有可能在堆中分配内存。如果没有开启逃逸分析,新创建的对象就会在堆中分配内存。
TLAB:
JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB(Thread-local allocation buffer,线程本地分配缓冲区)。默认设定为占用Eden Space的1%。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。
也就是说,Java中每个线程都会有自己的缓冲区称作TLAB(Thread-local allocation buffer),每个TLAB都只有一个线程可以操作,TLAB结合bump-the-pointer技术可以实现快速的对象分配,而不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。
如果对象在堆上分配内存,那么在给对象分配内存时,为什么需要锁住整个堆?
因为堆内存是所有线程共享的内存空间,如果一个线程创建对象,为对象在堆中申请了一块内存,这块内存还未申请完成,另一个线程也要创建对象,同时为对象在堆中申请了同一块内存,此时就会导致创建对象分配内存空间的线程安全问题。
JVM为对象分配内存的方式:指针碰撞法和空闲列表法
创建对象的大致过程:
1、假设通过new关键字创建对象,JVM遇到一条new指令,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载和初始化。如果没有,那么先执行类的加载和初始化。
2、类初始化完成之后,JVM为对象分配内存。对象所需的内存大小在类加载完成之后就知道了,分配内存不过是在java堆中(也有可能在栈中)划分一块确定大小的内存。
(1)如果内存是规整的,那么jvm采用的是指针碰撞法来为对象分配内存。什么是指针碰撞法呢?首先内存是规整的也就是说用过的内存在一边,空闲的内存在一边,中间放着一个指针作为分界点的指示器,分配内存就是将指针向空闲内存的那一边挪动与对象大小相等的距离。
(2)如果内存不是规整的,也就是说已经使用和空闲内存交织在一起,那么jvm将采用空闲列表法来为对象分配内存。什么是空闲列表法呢?当内存不是规整的,那么jvm就不得不维护一个内存列表,用来记录哪些内存是空闲的,哪些是已经使用了。为对象分配内存时,就从列表中找到一块足够大的空间分配给对象并更新列表。
什么时候使用指针碰撞法,什么时候使用空闲列表法呢?这和垃圾收集器有关。垃圾收集器如果使用的是复制算法或标记-整理算法,那么内存就是规整的,就使用指针碰撞法;如果使用的标记-清除算法,那么内存就是不规整的,就使用空闲列表法。
3、内存分配结束后,jvm将分配到的内存空间都初始化为零(不包括对象头)。这一步保证了对象实例不用赋值就可以直接使用,程序能访问到的字段的数据类型所对应的零值。此时对象还未初始化,如果此时使用对象,会带来安全隐患。
4、进行对象的必要设置,对象的哈希码,GC分代年龄,对象属于哪个类的实例等。
5、执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个可以使用的对象才算产生出来。
对象分配空间时的线程安全问题:为什么分配对象的时候会有线程安全问题?假如一个线程要创建一个A对象,采用指针碰撞法,正在分配但是还没有来得及修改指针位置,此时另一个线程要创建对象B,那么还是用的原来的指针来分配内存,所以会出现问题。
解决方案:
1、jvm采用CAS+失败重试保证更新操作的原子性;
2、采用TLAB方式,就是为每一个线程分配一块内存区域,每个线程创建对象时都在各自的区域内存分配空间。
给Java对象分配内存的过程:
1.编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
2.如果tlab_top + size <= tlab_end,则在TLAB上直接为对象分配内存并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则3.
3.重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
4.在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则5.
5.执行一次Young GC(minor collection)。
6.经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代。
对象不在堆上分配主要的原因还是堆是共享的,在堆上分配有锁的开销。无论是TLAB还是栈都是线程私有的,私有即避免了竞争(当然也可能产生额外的问题例如可见性问题),这是典型的用空间换效率的做法。
3、对象的创建过程
在Java中,一个对象在可以被使用之前必须要被正确地初始化(对象的初始化是对象创建过程的主要部分),这一点是Java规范规定的。在实例化一个类(即创建对象)时,JVM首先会检查相关类型是否已经加载并初始化,如果没有,则JVM立即进行加载并调用类构造器完成类的初始化。在类初始化过程中或初始化完毕后,根据具体情况才会去对类进行实例化。
Java对象的创建过程主要是以下两个步骤:
1、JVM在堆或栈中为对象分配内存,并为实例变量赋予默认初始值(零值);
2、按照程序员编写的代码为对象进行初始化,给实例变量赋予正确的初始值。
当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的实例变量及其从父类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏,也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照程序员的意图进行初始化。在Java对象初始化过程中,主要涉及三种执行对象初始化的结构,分别是实例变量初始化、实例代码块初始化和构造函数初始化。
在定义(声明)实例变量的同时,还可以直接对实例变量进行赋值或者使用实例代码块对其进行赋值。如果以这两种方式为实例变量进行初始化,那么它们将在构造函数执行之前完成这些初始化操作。实际上,如果对实例变量直接赋值或者使用实例代码块赋值,那么编译器会将其中的代码放到类的构造函数中去,并且这些代码会被放在对超类构造函数的调用语句之后(Java要求构造函数的第一条语句必须是超类构造函数的调用语句),自身构造函数的代码之前。
实例变量初始化与实例代码块初始化总是发生在构造函数初始化之前。众所周知,每一个Java中的对象都至少会有一个构造函数,如果我们没有显式定义构造函数,那么它将会有一个默认无参的构造函数。在编译生成的字节码中,这些构造函数会被命名成<init>()方法,参数列表与Java语言书写的构造函数的参数列表相同。
我们知道,Java要求在实例化类之前,必须先实例化其超类,以保证所创建实例的完整性。事实上,这一点是在构造函数中保证的:Java强制要求Object对象(Object是Java的顶层对象,没有超类)之外的所有对象构造函数的第一条语句必须是超类构造函数的调用语句或者是类中定义的其他的构造函数,如果我们既没有调用其他的构造函数,也没有显式调用超类的构造函数,那么编译器会为我们自动生成一个对超类构造函数的调用,如下图示。
创建对象一般就是上面两个过程:为对象分配内存并初始化。如通过new关键字创建对象new Object();,但是创建的Object对象并没有任何引用变量指向它,无法在任何地方使用它,垃圾回收器将对它进行回收。因此在创建对象时,一般都是这样写:Object o=new Object();,通过虚拟机栈中的引用变量o对新建的Object对象进行引用,可以通过引用变量o对这个Object对象进行操作。实际上引用变量o保存的是Object对象在堆内存中的地址值。因此创建一个对象也可以理解为包括三个步骤(实际上只有前两个步骤):
1、在堆中为对象分配内存;2、给对象进行初始化;3、在虚拟机栈中创建变量引用该对象。注意:第2步和第3步会涉及到指令重排序。在DCL(Double Check Lock)的单例中就会存在这个问题,使用volatile关键字解决指令重排序的问题。
下面这段代码是第一次创建一个类的对象时的执行顺序,第一次创建类的对象会涉及到类的初始化和类的实例化两个过程(懒加载情况下的过程,也就是使用类的时候才去加载和初始化类),后面再创建这个类的对象时,只会触发类的实例化。静态变量和静态代码块只会在第一次创建类的对象时会被执行,即在类的初始化阶段执行,类的初始化只被执行一次。实例变量、实例代码块和构造函数在每次创建对象时都会被执行,即在类的实例化阶段执行,类的实例化可被执行多次。
public class Parent {
//父类静态代码块
static{
System.out.println("父类静态代码块A");
//静态属性string_Parent_Static_A是在这个静态代码块的后面定义的,
//此处不能使用该静态属性,否则会报错。
//System.out.println(string_Parent_Static_A);
}
//父类静态属性
public static String string_Parent_Static_A = "父类静态属性A";
//父类无参构造函数
public Parent(){
System.out.println("父类无参构造函数");
}
//父类静态属性
public static String string_Parent_Static_B = "父类静态属性B";
//父类静态代码块
static{
System.out.println(string_Parent_Static_A);
System.out.println(string_Parent_Static_B);
System.out.println("父类静态代码块B");
}
//父类非静态代码块
{
System.out.println("父类非静态代码块A");
//非静态属性string_Parent是在这个非静态代码块后面定义的,此处不能使用string_Parent,
//否则会报错误:Cannot reference a field before it is defined
//System.out.println(string_Parent);
}
//父类非静态属性
public String string_Parent = "父类非静态属性";
//父类非静态代码块
{
System.out.println(string_Parent);
System.out.println("父类非静态代码块B");
}
}
public class Son extends Parent{
//子类静态属性
public static String string_Son_Static = "子类静态属性";
//子类静态代码块
static{
System.out.println(string_Son_Static);
System.out.println("子类静态代码块");
}
//子类无参构造函数
public Son(){
System.out.println("子类无参构造函数");
}
//子类非静态属性
public String string_Son = "子类非静态属性";
//子类非静态代码块
{
System.out.println("子类非静态代码块");
}
public static void main(String[] args){
new Son();//这里只是创建了对象,并没有引用变量指向该对象,无法使用它,会被GC回收
}
}
输出结果:
父类静态代码块A
父类静态属性A
父类静态属性B
父类静态代码块B
子类静态属性
子类静态代码块
父类非静态代码块A
父类非静态属性
父类非静态代码块B
父类无参构造函数
子类非静态代码块
子类无参构造函数
/*
* 总结:子类被new的过程中,初始化执行顺序如下:
* (1)如果子类继承了父类,先执行父类中的静态属性和静态代码块,
* 静态属性和静态代码块哪一个在前面就先初始化执行哪一个。
* (2)然后接着执行子类中的静态属性和静态代码块,哪一个在前就先执行哪一个
* (3)接着执行父类中的非静态属性和非静态代码块,
* 非静态属性和非静态代码块哪一个在前面就先初始化执行哪一个
* (4)接着执行父类中的构造函数
* (5)接着执行子类中的非静态属性和非静态代码块,哪一个在前就先执行哪一个
* (6)最后执行子类中的构造函数
* */
静态代码块是给静态属性进行初始化的,所以将静态属性放在静态代码块的前面。同样的,将非静态属性放在非静态代码块的前面。
如果类还没被加载到内存中,则类实例化的一般过程是:父类的类构造器<clinit>() -> 子类的类构造器<clinit>() -> 父类的成员变量和实例代码块 -> 父类的构造函数 -> 子类的成员变量和实例代码块 -> 子类的构造函数。
要想创建一个类的实例,必须先将该类加载到内存并进行初始化,也就是说,类初始化操作是在类实例化操作之前进行的,但并不意味着:只有类初始化操作结束后才能进行类实例化操作。如下示例:
public class StaticTest {
public static void main(String[] args) {
staticFunction();
}
static StaticTest st = new StaticTest();
static { //静态代码块
System.out.println("1");
}
{ // 实例代码块
System.out.println("2");
}
StaticTest() { // 实例构造器
System.out.println("3");
System.out.println("a=" + a + ",b=" + b);
}
public static void staticFunction() { // 静态方法
System.out.println("4");
}
int a = 110; // 实例变量
static int b = 112; // 静态变量
}
输出:
2
3
a=110,b=0
1
4
上面代码中,当JVM检测到需要执行main方法,就会将StaticTest类加载到内存中,放到运行时数据区的方法区,进行类的初始化操作,类初始化完成之后,在main方法中再调用静态方法staticFunction()。根据上面的分析可知,类的初始化其实就是执行类构造器<clinit>()方法的过程,而类构造器是由类中的静态变量和静态代码块组成的。上例对应的类构造器可简化成如下所示:
<clinit>(){
static StaticTest st = new StaticTest(); // 静态变量
static { //静态代码块
System.out.println("1");
}
static int b = 112; // 静态变量
}
在类构造器<clinit>()中调用了类的构造函数创建对象,即类的实例化。也就是说该例中在类初始化还未完成就开始了类的实例化。根据上面分析可知,类的实例化其实就是执行实例构造器<init>()的过程,简单理解为就是先执行实例变量赋值和实例代码块操作,然后再执行构造函数的过程。上例对应的实例构造器<init>()可简化成如下所示:
<init>(){
{ // 实例代码块
System.out.println("2");
}
int a = 110; // 实例变量
StaticTest() { // 实例构造器
System.out.println("3");
System.out.println("a=" + a + ",b=" + b);
}
}
因此该例中类初始化和类实例化的输出结果依次是:
2
3
a=110,b=0
1
类初始化完成之后,在主函数main中调用了静态方法staticFunction(),最后输出4。
下面这个流程图是对象创建过程,主要是对象在内存中的分配过程,类的初始化和对象的初始化过程没有详细画出来。
4、对象布局及访问定位
对象在内存中的布局
可以通过JOL(Java Object Layout)工具查看对象在内存中是如何布局的,在java中引入如下依赖,然后在代码中创建一个Object o=new Object()对象,并通过System.out.println(ClassLayout.parseInstance(o).toPrintable())打印出结果。
对象在内存中的存储布局:对象创建完成之后,一般是存储在堆内存中的,普通对象由三部分组成:对象头、实例数据和对齐填充(非必须的),数组对象由四部分组成:对象头、数组长度、实例数据和对齐填充(非必须的)。
1、对象头包括markword(标记字)和class pointer(类型指针)。markword中存储对象自身的运行时数据如哈希码、GC分代年龄和锁状态标识信息等。class pointer表示指向它的类元数据的指针,用于判断对象属于哪个类的实例。
在32位JVM中,markword和class pointer都是占用4个字节(一个字节占用8个二进制位,4byte共占用32bit)。在64位JVM中,markword占用8个字节(byte),class pointer占用4个字节(在64位JVM中指针实际占用8个字节,默认对指针进行压缩,因此64位JVM中指针也是占用4个字节)。所以,在32位JVM中,对象头共占用8个字节,在64位JVM中,对象头共占用12个字节,对象中第一个实例变量的偏移量也是12,就是由此而来的。synchronized对对象加锁时,锁对象的一些标记信息就是存储在markword中的。
2、如果对象中有实例变量(包括父类的属性),实例数据根据自身的类型也会占用一定的字节,如int i会占用4个字节,String s也会占用4个字节(在32bit的JVM中,普通对象指针占用4byte,在64bit的JVM中,普通对象指针实际占用8byte,默认对普通对象指针进行压缩)。上例中new Object中没有实例变量,所以实例数据没有占用字节。
3、虚拟机要求对象字节必须是8字节的整数倍,如果对象头加上实例数据占用的字节不是8的整数倍,就需要使用多余的字节进行对齐填充,使其满足对象字节是8的整数倍(上例中填充了4个字节来进行对齐,使得(12+4)%8=0)。
因此,在32bit的JVM中,一个Object对象占用8byte,在64bit的JVM中,一个Object对象占用16byte(8byte的markword+4byte的class pointer+4byte的对齐填充)。
synchronized给对象进行加锁,对象的锁信息是存储在对象头的markword中的。
在32位的虚拟机中,markword占用32bit,即4byte,markword中存储的信息如下所示:
在64位的虚拟机中,markword占用64bit,即8byte,markword中存储的信息如下所示:
在64bit的VM中(可以理解为64位的操作系统),指针占用64bit,即8byte,默认对类型指针(class pointer)和普通对象指针(Ordinary Object Pointer)进行压缩。可以通过命令java -XX:+PrintCommandLineFlags -version查看JVM的参数信息、版本信息、虚拟机的位数和JIT的模式信息等。如下图所示,参数-XX:+UseCompressedClassPointers表示对类型指针进行压缩,参数-XX:+UseCompressedOops表示对普通对象指针进行压缩。如果要关闭对类型指针或普通对象指针的压缩,可以在启动服务之前,在idea中通过run->Edit configuration配置VM options的值为-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops即可(将+变成-),可以同时配置这两个参数,对类型指针和普通对象指针都不压缩。
32位虚拟机 VS 64位虚拟机:
由于指针膨胀和各种数据类型对齐补白的原因,运行于64位系统上的Java应用需要消耗更多的内存(通常比32位的增加10%~30%的内存开销) ;此外,64位虚拟机的运行速度比32位的大约有15%左右的性能差距。
不过,64位虚拟机也有它的优势:首先能管理更多的内存,32位最多4GB,实际上还受OS允许进程最大内存的限制(Windows下2GB);其次,随着硬件技术的发展,计算机终究会完全过渡到64位,虚拟机也将过渡到64位。
对象的访问定位
对象的访问定位也取决于具体的虚拟机实现。当我们在堆上创建一个对象实例后,就要通过虚拟机栈中的reference类型数据来操作堆上的对象。现在主流的访问方式有两种(HotSpot虚拟机采用的是第二种):
1、使用句柄访问对象。即reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。
2、直接指针访问对象。即reference中存储的就是对象地址,相当于一级指针。
两种方式有各自的优缺点。当垃圾回收移动对象时,对于方式一而言,reference中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;而对于方式二,则需要修改reference中存储的地址。从访问效率上看,方式二优于方式一,因为方式二只进行了一次指针定位,节省了时间开销,而这也是HotSpot采用的实现方式。下图是句柄访问与指针访问的示意图。