虚拟机中对象的创建过程
JVM遇到一条字节码new指令,首先会检查常量池中是否有该对象所属类的符号引用,如果找不到就先执行类加载,在类加载检查通过后就会给新生的对象分配内存,然后内存空间初始化,这里不是构造方法,而是将分配的内存初始化为零值,然后设置保存对象头信息,最后进行对象的初始化。
给对象分配内存有两种方式:指针碰撞、空闲列表。分配内存用的哪种方法,是由内存规整程度决定的,内存规整程度又是由垃圾回收器决定。
- 指针碰撞
如果堆中的内存是规整的,也就是说使用中的内存在一边,空闲内存在另一边,中间有一个指针作为分界点指示器。那么只需要把指针向空闲区域挪动一段与新对象大小相等的距离。
什么样的情况下堆内存是规整的呢?当然是经过整理的,比如 JVM 的垃圾收集器采用复制算法或标记-整理算法,那么堆内存是相对规整的。
- 空闲列表
如果堆中的内存不是规整的,而是已使用内存和未使用内存交错的,那么就需要虚拟机维护一个列表并记录哪些内存是可用的。在可用内存中找到一块足够大的空间划分给新对象。
JVM 的垃圾收集器采用标记-清除算法,就会使用这种方式分配内存。
不过,上面这两种分配内存的方法都是线程不安全的。一般解决方法也有两种:CAS加载失败重试、本地线程分配缓冲
- CAS加载失败重试
1 | 对分配内存空间的动作进行同步处理–虚拟机采用CAS配上失败重试保证更新操作的原子性 |
- 本地线程分配缓冲 TLAB
1 | 每个线程在 Java 堆中(一般是在堆中的Eden区)都预先分配一块小内存叫TLAB, |
对象的访问
- 使用句柄
在java堆里划分一块句柄池的区域,用于存放所有对象的地址和对象所属类信息。
引用类型变量存放的地址是该对象在句柄池中的地址,访问对象时首先通过该对象的句柄,然后根据句柄再访问该对象。
2. 直接指针
通过引用直接访问该对象的地址,但对象所在内存空间需要额外的内存策略来记录该对象在方法区中的类信息的地址。 HotSpot使用的是直接指针访问的方式。
对象的存活
判断对象的存活有引用计数法和可达性分析。
- 引用计数法
1 | 就是有一个地方引用了这个对象,计数器值就+1,释放了就-1 |
- 可达性分析
可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达,使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链,如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
主流虚拟机基本用的是可达性分析。
那么经过可达性分析后,对象一定会死吗?当然是不一定的。JVM留了一个方法finalize()可以拯救一下这个对象。finalize()是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它finalize()方法。不过优先级很低,需要让线程等待一会,所以也没必要这么用。。。
对象的引用
- 强引用
1 | Object object=new Object(); |
- 软引用 SoftReference
1 | Teacher t1=new Teacher(); |
- 弱引用 WeakReference
1 | 只要垃圾回收,这个对象就会被回收。 |
- 虚引用 PhantomReference
1 | ReferenceQueue<String> queue=new ReferenceQueue<>(); |
对象的分配策略
几乎几乎几乎所有的对象都在堆中分配。堆分为Eden区、From区、To区、老年代。老年代占堆的2/3,新生代(Eden区、From区、To区)占1/3,新生代三个区空间一般默认8:1:1。
new一个对象后,会判断是否可以在栈上分配,可以就在栈上分配,不可以的话就在堆中分配。如果是个大对象则进入老年代,不是的话就在Eden分配。如果启动了本地线程分配缓冲,则优先在TLAB上分配,无论是在TLAB还是在Eden的其它地方,分配的对象越来越多的时候,会触发GC,因为Eden区只存放新生对象,一旦GC后有的对象就直接没了,有的存活的对象也不会在Eden区停留,一般会进入From区,此时该对象的age+1。当From区GC的时候,这里的存活对象进入To区,该对象age+1。To区GC的时候活着的对象进入From区,age+1,当一个对象的age到了一定程度(默认15)则进入老年代,进入老年代的对象的对象头age不再+1,没有意义了。
不管是大对象直接进入老年代还是对象从To区晋级到老年代,都有可能担心老年代空间不够用,JVM说没必要这个时候一定要老年代GC一下,我来担保空间是够用的,要是真的不够用了才会GC。
新生代采用的是复制回收算法,为什么不干脆一分为二,而是分了三个区呢?一分为二的话空间只有50%的利用率,而经过数据分析90%的对象会在第一次GC的时候回收掉,存活的只有10%,所以分三个区可以提高空间利用率,要么From区闲着要么To区闲着,可以达到90%。
是否在栈中分配对象需要逃逸分析,也就是要分析这个对象的作用域,看看它是不是逃逸出方法,还有有没有被其它线程操作。满足逃逸分析的话就会在栈中分配对象。
为什么要在栈中分配对象?因为栈是线程私有的,跟随线程的生命周期,不需要垃圾回收器回收,提高JVM效率。
垃圾回收算法与垃圾收集器
在C或者C++中我们都会手动回收,但是在java中我们都是直接new对象,也没有考虑过怎么回收的,这是因为有垃圾回收器(GC)帮我们做了这份工作。GC主要回收的是堆里的东西。
新生代采用的复制算法,老年代采用的标记清除算法或者标记整理算法。
标记-清除算法
导致内存碎片(内存不是连续的),进而提前导致GC。
复制算法
也是从根集合开始遍历,比如A引用可达,那么就会把A复制到一个另一块空闲内存,B引用不可达,那么不处理……等所有的处理完,把原来那块内存空间清空,只保存复制后的那块内存空间。
实现简单、运行效率高、没有内存碎片,但是利用率只有一半。所以新生代采用的是优化的复制算法,加入了Eden区,利用率达到90%。
标记-整理算法
这种算法在GC后,会把不可回收的对象移动整理成连续的,这样解决了标记-清除算法带来的内存碎片问题,但是因为有对象的移动,而且是老年代对象的移动,这样量大,也会引起引用的更新,会导致用户线程暂停,所以整体上来说效率偏低。
常量池与String
常量池
分为静态常量池和运行时常量池。
静态常量池,即*.class字节码文件中的常量池,不仅仅包含字符串(数字)字面量、符号引用,还包含类、方法的信息,占用class文件绝大部分空间。
运行时常量池,是方法区的一部分,是一块内存区域。静态常量池将在类加载后进入方法区的运行时常量池中存放。一个类加载到 JVM 中后对应一个运行时常量池,运行时常量池相对于 静态常量池来说具备动态性,静态常量池只是一个静态存储结构,里面的引用都是符号引用。而运行时常量池可以在运行期间将符号引用解析为直接引用。
字符串常量池在,在JDK1.7以前也是方法区的一部分, JDK1.7(含)之后,是在堆内存之中,存储的是字符串对象的引用,字符串实例是在堆中。
String的创建分配内存地址
String加了final,不可被继承。
- String str=”abc”
JVM首先会检查该对象是否在字符串常量池中,如果在就返回该对象的引用,否则新的字符串将在常量池被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。
- String str1=new String(“abc”)
首先在编译类文件时,”abc”常量字符串将会放入到常量结构重,在类加载时,”abc”将会在常量池中创建;其次在调用new时,JVM将会调用String的构造函数,同时引用常量池中的”abc”字符串,在堆内存中创建一个String对象,最后str1将引用String对象
- String str2=”ab”+”cd”+”ef”
首先会生成ab对象,在生成abcd对象,在生成abcdef对象,所以效率很低。
- intern()
1 | //"liyifeng"会在常量池中创建,new以后会在堆里创建一个a的String对象 |