Skip to content

创建对象

Java中创建对象的方式?

Java中创建对象的方式主要有以下几种:

  1. 使用new关键字:直接在堆内存上创建对象,并调用相应的构造函数。
  2. 使用Class反射调用:通过Java的反射机制,使用Class类的newInstance方法或Constructor类的newInstance方法来创建对象。
  3. 使用Clone方法:调用一个对象的clone方法,JVM会创建一个新的对象,并将原对象的内容拷贝到新对象中。但使用clone方法前,对象必须实现Cloneable接口并覆盖其定义的clone方法。
  4. 使用序列化:如果一个对象实现了Serializable接口,就可以将其写入文件或通过网络发送到其他运行Java虚拟机的系统,然后再通过读取文件或接收数据来重新构造对象。

创建对象的过程是什么?

Java对象内存分配流程可以清晰地分为以下几个步骤:

  1. 类加载

    • Java虚拟机会首先加载类的定义,包括字段和方法。类的定义通常是从类文件(.class文件)加载的。
  2. 确定对象大小

    • 在类加载过程中,Java虚拟机会确定对象所需内存的大小,这包括对象头、实例数据和填充数据的大小。

    • 在分配对象之前,虚拟机需要知道对象的确切大小。这包括对象头(包含元数据信息如哈希码、锁状态等)、实例数据(即对象的字段或成员变量的值)和可能的填充数据(用于对齐对象的起始地址以提高访问效率),对象的大小在对象生命周期内保持不变。

  3. 分配内存

    • 一旦对象的大小确定,Java虚拟机会从堆内存中为对象分配相应大小的内存空间。

    • 分配内存的方式有两种:

      1. 如果堆内存是规整的(即已使用的内存在一边,未使用的内存在另一边),则使用“指针碰撞”的方式分配内存;
      2. 如果堆内存不规整,则使用“空闲列表”的方式从列表中找一块足够大的空间划分给对象实例。
    • 内存分配过程可能不是线程安全的,Java虚拟机可能会使用如TLAB(Thread-Local Allocation Buffer)等技术来减少多线程间的竞争。

  4. 初始化对象

    • 在分配内存后,JVM会对这块内存进行初始化,通常会将内存置为零值或默认值,这意味着对象的实例变量会被初始化为其类型的默认值(如0、false或null)。
  5. 设置对象头信息

    • 在对象初始化之后,Java虚拟机会设置对象头中的元数据信息。

    • 对象头(Object Header)是每个对象的一部分,它包含两类信息:

    • Mark Word:用于存储对象的运行时数据,例如哈希码、GC分代年龄、锁状态标志等。

    • Class Pointer:类型指针,指向该对象的类元数据的指针,JVM通过这个指针确定对象所属的类。

  6. 实例变量初始化:

    • 如果有显式初始化语句或者构造函数,接下来会执行这些初始化操作,赋予实例变量具体的值。
  7. 对齐填充(如果需要):

    • 为了满足特定的对齐要求(通常是8字节的倍数),可能会在对象末尾添加一些额外的字节作为填充,以保持对象的内存地址对其。
  8. 返回对象引用

    • 当对象被成功创建并初始化后,Java虚拟机会返回对象的引用。这个引用可以被存储在变量中,供后续的程序代码使用。

需要注意的是,虽然大多数情况下对象都是在堆内存中分配的,但在某些特定情况下(如通过逃逸分析确定对象不会被外部引用时),JVM可能会选择在栈上分配对象,以减少垃圾收集的压力和提高内存的使用效率。

此外,Java的内存管理模型还包括了分代垃圾回收等优化技术,这些技术可以进一步提高内存的使用效率和程序的性能。整个过程中,如果在内存分配或初始化阶段发生异常(如内存不足),JVM会抛出相应的错误或异常,如 OutOfMemoryError。

子类初始化的顺序?

  1. 父类静态代码块和静态变量。
  2. 子类静态代码块和静态变量。
  3. 父类普通代码块和普通变量。
  4. 父类构造方法。子类会默认调用父类的无参数的构造方法。如果父类无默认构造方法,必须通过super()手工指定构造方法,且必须在方法第一行调用,否则会抛出异常。
  5. 子类普通代码块和普通变量。
  6. 子类构造方法。

构造方法有哪些特点?

构造方法特点如下:

  • 名字与类名相同。
  • 没有返回值,但不能用 void 声明构造函数。
  • 生成类的对象时自动执行,无需调用。
  • 构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。

Java中数组是不是对象?

是对象,数组是指具有相同类型的数据的集合,它们一般具有固定的长度,并且在内存中占据连续的空间。

在Java语言中,数组不仅有其自己的属性(例如length属性),也有一些方法可以被调用(例如clone方法)。由于对象的特点是封装了一些数据,同时提供了一些属性和方法,从这个角度来讲,数组是对象。每个数组类型都有其对应的类型,可以通过instanceof来判断数组的类型

Java中数组是不是对象?

NEW:

  1. JVM 首先将检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查引用代表的类是否已被加载,如果没有就先执行类加载。
  2. 在类加载检查通过后虚拟机将为新生对象分配内存。
  3. 将对象成员变量设为零值,保证对象的实例字段可以不赋初值就使用。
  4. 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
  5. 最后将指向实例对象的引用变量压入操作数栈栈顶

DUP

复制操作数栈顶值,并将其压入栈顶,这时操作数栈有两个指向堆内实例的引用变量。两个引用变量的目的不同,栈底的引用用于赋值或保存局部变量表,栈顶的引用作为句柄调用相关方法。

INVOKESPECIAL

调用 <init>()方法,该方法是实例方法,需通过弹出栈顶的引用变量调用。该方法包括:初始化成员变量,执行实例化代码块,调用类的构造方法。

对象分配内存的方式有哪些?

对象所需内存大小在类加载完成后便可完全确定,分配空间的任务实际上等于把一块确定大小的内存块从 Java 堆中划分出来。

  • 指针碰撞: 假设 Java 堆内存规整,被使用过的内存放在一边,空闲的放在另一边,中间放着一个指针作为分界指示器,分配内存就是把指针向空闲方向挪动一段与对象大小相等的距离。
  • 空闲列表: 如果 Java 堆内存不规整,虚拟机必须维护一个列表记录哪些内存可用,在分配时从列表中找到一块足够大的空间划分给对象并更新列表记录。

选择哪种分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否有空间压缩能力决定。使用 Serial、ParNew 等收集器时,系统采用指针碰撞;使用 CMS 这种基于清除算法的垃圾收集器时,采用空间列表,如何在空闲列表中找到最合适的内存空间,有First Fit、Best Fit和Worst Fit等方法。

对象分配内存是否线程安全?

对象创建十分频繁,即使修改一个指针的位置在并发下也不是线程安全的,可能正给对象 A 分配内存,指针还没来得及修改,对象 B 又使用了指针来分配内存。

解决方法:

  • CAS 加失败重试保证更新原子性。
  • 把内存分配按线程划分在不同空间,即每个线程在 Java 堆中预先分配一小块内存,叫做本地线程分配缓冲区(TLAB),哪个线程要分配内存就在对应的 TLAB 分配,TLAB 用完了再进行同步。

什么是栈上分配?

Java的对象一般都是分配在堆内存中,JVM开启了栈上分配后,允许把线程私有的对象(其它线程访问不到的对象)打散分配在栈上。这些分配在栈上的对象在方法调用结束后即自行销毁,不需要JVM触发垃圾回收器来回收,因此提升了JVM的性能。栈上分配需要有一定的前提:

  • 开启逃逸分析 (-XX:+DoEscapeAnalysis) 逃逸分析(Escape Analysis)并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,如作为调用参数传递到其他方法中,这种称为方法逃逸;有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化。
  • 开启标量替换 (-XX:+EliminateAllocations),默认该配置为开启。 标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。如果把一个Java对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上,栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储,分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

什么是线程本地分配缓冲区?

启用线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB ) 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域,在预留这个动作发生的时候,需要进行加锁或者采用 CAS 等操作进行保护,避免多个线程预留同一个区域。

一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。

这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作

它的主要目的是在多线程并发环境下进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。

TLAB的本质其实是三个指针管理的区域:start,top 和 end,其中 start 和 end 是占位用的,标识出 Eden里被这个 TLAB 所管理的区域。TLAB让每个线程有私有的分配指针,但对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。在Java对象分配时,如果剩余空间(end-top)大于待分配对象的空间(objSize),则直接修改top=top+ObjSize,表示分配成功,如果不能分配,但是当前 TLAB 剩余空间小于最大浪费空间限制(refill_waste),则从Eden区重新申请一个新的 TLAB 进行分配。否则,直接在 TLAB 外进行分配。

虚拟机内部会维护一个叫做refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象,而在老TLAB里的对象还留在原地什么都不用管它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。而老的TLAB在被退回堆之前,需要填充好 dummy object。

由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个 int[] 的 header,所以 TLAB 的大小不能超过int 数组的最大大小,否则无法用 dummy object 填满未使用的空间。但是,填充 dummy 也造成了空间的浪费,这种浪费不能太多,所以通过最大浪费空间限制来限制这种浪费。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。TLAB大小和refill_waste都会在运行时不断调整,使系统的运行状态达到最优。TLAB空间由于比较小,因此很容易装满。

对象分配策略有哪些?

  • 对象优先在Eden区分配 ,大多数情况下,对象在新生代的Eden区分配,如果Eden区没有足够的内存空间,虚拟机会发起一次Minor GC。
  • 大对象直接进入老年代 ,所谓的大对象,指的是需要大量连续内存空间的java对象,比如很长的字符串和数组,虚拟机可以设置-XX:PretenureSizeThreshold参数,令大于这个参数的对象直接在老年代分配,默认值是0,意思是不管多大都是先在Eden中分配内存
  • 长期存活的对象将进入老年代 ,在Eden区的对象,每熬过一次Minor GC,它的年龄数就加一,当它的年龄数到达一定值时(默认15),就会晋升到老年代中,虚拟机可以通过设置-XX:MaxTenuringThreshold参数,决定年龄阈值
  • 动态年龄判断,如果在Survivor区的相同年龄所有对象的大小之和大于Survivor空间的一半,年龄大于或等于该年龄的对象就直接进入老年代,无需等待到MaxTenuringThreshold中要求的年龄
  • 分配担保,在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有的对象空间,如果条件成立,那么Minor GC是确保安全的;如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可利用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管该次GC会有风险;如果小于或者HandlePromotionFailure不允许冒险,那这时需要进行一次Full GC

对象的内存布局是怎样的?

对象在堆内存的存储布局可分为对象头、实例数据和对齐填充。

  • 对象头(Header):包括对象标记、类型指针和数组长度(只有数组对象有)

    • Mark Word :对象标记用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit。 Mark Word 被设计为动态数据结构,以便在极小的空间存储更多数据,根据对象状态复用存储空间。

    • klass :类型指针指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象 的元数据信息并不一定要经过对象本身。 该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit,开启压缩指针为32 bit。

    • 数组长度:如果对象是一个数组,那在对象头中还必须有一块数据用于记录数组长度。数据长度32bit。

  • 实例数据(Instance Data)

    • 是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

    • 无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

    • 存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。

    • HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)

    • 相同宽度的字段总是被分配到一起,整体是按从大到小排列的,对象的引用放在最后。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

    • 如果 CompactFields参数(Jdk14中被弃用了)默认为true,那子类之中较窄的变量也可能会插入到父类变量的空隙之中

  • 对齐填充(Padding)

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

对象头Mark Word 存储内容有哪些?

象头中的Mark Word在Java中存储了与对象状态和运行时数据相关的信息。根据参考文章的内容,我们可以将Mark Word的存储内容归纳如下:

  1. 锁状态信息

    • lock:2位的锁状态标记位,表示对象的线程锁状态。

    • biased_lock:1位的标记位,表示对象是否启用偏向锁。

    • lock和biased_lock共同表示对象处于什么锁状态(无锁、偏向锁、轻量级锁、重量级锁)。

  2. GC分代年龄

    • age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。
  3. 对象标识hashCode

    • identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。当对象加锁后(偏向、轻量级、重量级),Mark Word的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
  4. 偏向线程ID(可选)

    • 当对象启用偏向锁时,Mark Word中会存储偏向线程的ID。
  5. 其他可能的运行时数据

    • 根据对象的具体状态,Mark Word还可能存储其他与对象运行相关的数据。

需要注意的是,Mark Word的具体内容和大小可能会根据JVM的实现和配置有所不同。

  • 在64位系统中,Mark Word通常占用了8个字节(64位)
  • 在32位系统中则可能占用更少的空间。
  • 此外,JVM还提供了一些参数(如-XX:+UseCompressedOops和-XX:+UseCompressedClassPointers)来优化Mark Word和其他指针的存储方式,以减少内存使用。

Java对象内存分配过程中涉及哪些指针操作

在Java对象内存分配过程中,指针操作主要涉及以下几个关键步骤,以下是对这些步骤的详细解释和归纳:

  1. 对象头(Object Header)的指针操作

    • 对象头中包含了对象的元数据信息,如哈希码、锁状态等。这些信息在对象分配和初始化过程中会被设置。

    • 对象头中的_metadata部分是一个指针,它指向该对象的类元数据(Class Metadata)。类元数据包含了对象的类型、父类、实现的接口、方法表等信息。

  2. 内存分配时的指针操作

    • 指针碰撞(Bump The Pointer):如果Java堆内存是规整的,那么内存分配器可以通过一个指针来跟踪哪部分内存是空闲的。当需要为对象分配内存时,分配器只需将指针向空闲内存方向移动与对象大小相等的距离即可。这个“移动指针”的操作实质上就是一次指针操作。

    • 空闲列表(Free List):如果Java堆内存不是规整的,那么内存分配器需要维护一个空闲列表,其中记录了可用的内存块。当需要为对象分配内存时,分配器会从列表中查找一个足够大的空闲内存块,并将其从列表中移除(即更新列表的指针或索引)。这也是一种指针操作。

  3. TLAB(Thread-Local Allocation Buffer)的指针操作

    • 为了减少多线程间的竞争,Java虚拟机可能会为每个线程分配一个TLAB。线程在自己的TLAB上分配对象时,会涉及到对TLAB边界指针的更新操作。当TLAB用完需要分配新的缓存区时,也会涉及到与堆内存管理相关的指针操作。
  4. 对象引用的指针操作

    • 当对象被成功创建并初始化后,Java虚拟机会返回对象的引用。这个引用实际上是一个指针,它指向了对象在堆内存中的起始地址。后续的程序代码可以通过这个引用来访问和操作该对象。
  5. 垃圾回收时的指针操作

    • 虽然这不直接属于对象内存分配过程的一部分,但垃圾回收过程中也会涉及到大量的指针操作。例如,标记-清除算法需要遍历所有的对象引用(即指针),以确定哪些对象是不可达的(即不再被引用的);复制算法和标记-压缩算法则需要移动对象的内存位置,并更新所有指向这些对象的引用(即更新指针的值)。

总结来说,Java对象内存分配过程中涉及的指针操作主要包括对象头中元数据的指针设置、内存分配时的指针碰撞或空闲列表操作、TLAB的边界指针更新以及对象引用的返回和后续使用中的指针操作。这些指针操作共同确保了Java对象在内存中的正确分配、初始化和访问。

基于 MIT 许可发布