一、堆和栈的区别
堆(Heap)和栈(Stack)是两种基本的数据结构,它们在内存管理、程序执行流程控制等方面扮演着重要角色。在编程语言尤其是Java这样的高级语言环境中,堆和栈的概念被用来描述程序运行时的内存布局。以下是它们之间的一些关键区别:
1. 物理地址
堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代 (即新生代使用复制算法,老年代使用标记——压缩)
栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
2. 内存分别
堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般 堆大小远远大于栈。
栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
3. 存放的内容
堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。
PS: 1. 静态变量放在方法区 2. 静态的对象还是放在堆。
4. 程序的可见度
堆对于整个应用程序都是共享、可见的。
栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。
二、内存分配与处理并发安全问题
1. 内存分配
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java 堆是否规整,有两种方式:
1. 指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的 放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小 相等的距离,这样便完成分配内存工作。
2. 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。 选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所 采用的垃圾收集器是否带有压缩整理功能决定
2. 处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
1. 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性)
2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
三、对象定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。
指针: 指向对象,代表一个对象在内存中的起始地址。
句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。
1. 句柄访问
Java堆中划分出一块内存来作为句柄池,每个对象在堆中都有一个对应的句柄。句柄包含两部分信息:对象实例数据的指针和类型数据(如类的元数据)的指针。而栈上的引用变量存储的是句柄池中句柄的地址。具体构造如下图所 示:
优势:如果对象被移动(如垃圾回收时),只需要修改句柄池中的实例数据指针即可,栈上的引用和句柄池中的类型数据指针不需要改变,这降低了维护引用的开销。
缺点:每次访问对象都需要两次间接寻址(先通过引用找到句柄,再通过句柄找到实际对象),这可能会增加访问时间。
2. 直接指针
如果使用直接指针访问,引用中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。
缺点:如果对象被移动,所有指向该对象的引用都必须更新,这在垃圾回收时可能会增加一定的开销。
Java虚拟机的具体实现(如HotSpot JVM)可以选择使用其中一种方式,或者根据情况动态选择。早期的HotSpot JVM倾向于使用直接指针访问以提高性能,但在某些特定配置或JVM版本中,可能会采用句柄访问以适应特定场景的需求。