返回
Featured image of post Java - JVM 问题集合 (下)

Java - JVM 问题集合 (下)

翻了几个题库,结合以前笔记整理了JVM相关一些题目的答案.

内存、对象、JVM优化相关问题

指令重排序与 happens-before 原则

https://www.jianshu.com/p/b9186dbebe8e

问题的起因还是指令重排序,重排序之所以存在是因为有多级缓存,线程所作变更先在寄存器或本地缓存完成,然后拷贝到主存以跨越内存栅栏(即完成工作内存到主存间的拷贝动作),跨越序列或顺序称为happens-before。仅当写操作线程先跨越内存栅栏,读线程后跨越内存栅栏,写操作线程所作变更才对其它线程可见。

  • 程序次序规则: 在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作 (同一个线程中前面的所有写操作对后面的操作可见
  • 管理锁定规则:一个unlock操作happen—before后面(时间上的先后顺序)对同一个锁的lock操作。 (如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  • volatile变量规则:对一个volatile变量的写操作happen—before后面(时间上)对该变量的读操作。 (如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  • 线程启动规则:Thread.start()方法happen—before调用用start的线程前的每一个操作。 (假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。
  • 线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。 (线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)
  • 线程中断规则:对线程interrupt()的调用 happen—before 发生于被中断线程的代码检测到中断时事件的发生。 (线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。 (对象调用finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部cache。)
  • 传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。

内存泄漏

JVM通过引用计数法和可达性分析算法来判断对象是否可以被GC,对象长时间没有被GC一般是JVM误以为对象还在引用中,无法回收,即长期存活对象引用短期存活对象,常见的情况包括:

  • 静态集合类(短生命周期对象)被长生命周期持有引用
  • 各种连接(数据库、网络连接、IO)没有线性关闭,造成对象无法被回收
  • 变量定义作用范围过大
  • 内部类持有外部类,内部类长期被持有,导致外部类对象无法被回收
  • 哈希表中,如果对象哈希值与最初存入时不同,则会导致找不到对象结果,造成泄漏
  • 一些数据结构,如手写栈,在栈不断增长、收缩后,如果不手工清空(引用置null),弹出对象有可能不被GC,造成隐蔽的内存泄漏
  • 缓存结构中,有些对象写入后很可能就再也没有被删除或使用,造成缓存泄漏。WeakHashMap 就是为此诞生的,没有除自身外其它引用的值会被丢弃
  • 监听器和回调,如果注册后没有显示取消就会积聚起来,比较简单的解决方法也是使用弱引用

因此总结下来,防止内存泄漏的办法包括:避免长期存活对象持有不必要的短期对象引用;不使用资源即时回收,尤其是连接、手工数据结构中;难以判断是否可以回收的缓存资源,可以通过弱引用防止内存泄漏。


Java 中的四种引用类型

不同引用类型体现为对象不同的可达性状态和对垃圾收集的影响。

  • 强引用就是最常见引用,垃圾收集器不会处理强引用指向的对象,但只要超过了引用的作用域,或显式地赋值为null就可以被回收
  • 软引用相对强引用弱化一些,只有JVM认为内存不足(由JVM进行判定,不同JVM不同模式下对剩余空间的考量不同)时才会取试图回收软引用指向对象,因此可以用于缓存
  • 弱引用指向对象完全不能从GC豁免,仅提供访问在弱引用状态下对象的途径,可以用来维护非强制的映射关系,如果还在就获取,否则重现实例化,也可以用于缓存
  • 虚引用不能被用来访问对象,仅提供确保对象被finalize后做某些事的机制,比如 Post-Mortem 清理机制,Cleaner,监控对象的创建和销毁

引用的可达性可以进行流转,除了虚引用外的引用都可以通过get方法获取原有对象,从而重新指向强引用,人为改变对象的可达性状态。

但是如果错误地保持了强引用,那对象就没有机会变回弱引用可达性状态了,就会产生内存泄露。


Native 关键字

用于告诉 JVM 调用的是本地 C/C++ 方法,因此不能 abstract。使用 JNI 加载动态或静态库时会需要用到这个方法。常见的如 hashCode() 、NIO 中 epoll 相关的内核操作都需要调用本地方法。


Unsafe 类

可以被用来分配直接内存、获取内存偏移地址、根据偏移地址修改属性(无视对象的可访问等级)、操作数组元素、线程挂起恢复、CAS ……

访问直接内存、调用直接内存都通过这个类来实现,记住这个名字 sun.misc.Unsafe


Object o = new Object() 占几个字节

对象头里的 Mark Word, Class Pointer, 数组 Length 都是必须长度

  • Mark Word包括所的信息、GC标记信息、哈希码,总共8字节
  • Class Pointer,对于64位系统就是8字节
  • Length,数组特有结构,4字节

所以,普通对象就是 8+8 ,一共16字节;数组对象就是 8+8+4,对其8的整数倍,所以24字节


对象大小为什么是8字节的整数倍

数据结构对齐,为了允许以一些空间为代价加快内存访问,这和C中结构体是一样的。同时,这也是最方便垃圾回收的大小。


对象是如何定位的

查了一下《深入理解Java虚拟机》,首先JVM规范没有规定引用应该怎样定位对象、访问堆上的具体位置,因此取决于具体实现,主流方法是句柄、直接指针:

  • 如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
  • 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址

句柄访问,则引用中地址很稳定,即使GC过程中,对象被移动,只有句柄被改变,引用不用变;直接指针好处当然就是快,HotSpot 用的也是直接指针。


如何实现对象克隆

Object.clone() 方法返回的就是 Object 的拷贝,不过必须实现Cloneable接口,这是个没有方法的标志性接口。

值得注意的是,clone()得到的是浅拷贝。浅拷贝是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。深拷贝不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。

简单的深拷贝方法是将对象序列化,会将引用对象也一并保存下来,代价是耗时长。


对象不再使用时是否需要写null

《Effective Java》中讨论过这个问题,结论是,清空对象引用是一种例外,不是规范行为,Java 本身具有 GC,不需要所有对象手动写null。

例外是自己管理内存的情况,前文也提到过,手工栈,从栈中弹出对象即使不被引用也会受栈维护,不被作为GC对象,必须手动删除。需要写null的情况有:

  • 自己管理内存

    就是以上的情况。

  • 缓存对象的引用

    对这种情况,当缓存项由对应键的外部引用决定生命周期,可以使用 WeakHashMap 进行缓存;LinkedHashMap 利用 removeEldestEntry 可以在定时清除缓存时使用。

  • 监听器和其它回调

    客户端在 API 中注册回调,却没有显式取消,则最好通过只保存回调的弱引用(weak reference)来防止泄露。

其实这也和之前内存泄漏问题耦合度很高。

个人认为,Java 中的null是一个比较一言难尽的设计,尽量减少向代码中人为引入null的做法。


JVM 调优命令和问题定位工具

调优命令太多了,参考这篇

https://www.cnblogs.com/xifengxiaoma/p/9415357.html

强调几个常用的:jstack用于定位线程异常,包括死锁、死循环;jmap用于分析内存问题,如溢出、频繁Full GC;jstat用于监控GC相关信息;jps查看系统Java进程

问题定位工具,除了调优命令,还有JDB、VisualVM、JConsole、JProfiler、BTrace等等


宏变量与宏替换

final定义的变量,编译器会尝试对其进行宏替换,如以下程序片段:

final String a = "hello";
final String b = a;
final String c = getHello();

a 会在编译期间被全部替换成"hello",而b、c不行,因此 a 是宏变量,bc 不是。

有这样一段程序:

public static void main(String[] args) {
    String hw = "hello world";

    String hello = "hello";
    final String finalWorld2 = "hello";
    final String finalWorld3 = hello;
    final String finalWorld4 = "he" + "llo";

    String hw1 = hello + " world";
    String hw2 = finalWorld2 + " world";
    String hw3 = finalWorld3 + " world";
    String hw4 = finalWorld4 + " world";

    System.out.println(hw == hw1);
    System.out.println(hw == hw2);
    System.out.println(hw == hw3);
    System.out.println(hw == hw4);
}

原文链接https://blog.csdn.net/youanyyou/article/details/78990305

finalWorld2 finalWorld4 就是宏变量,最终结果它们会和 hw 相等,因为就是指向同一块字符串常量池。而 finalWorld1 finalWorld3 就不会了,这在字符串篇会提到,StringBuilder 的合并是个比较例外的操作,会新建一个对象,两者都和 hw 不等。

所以结果是:false true false true


怎样查看字节码

JDK 提供了 javap ;IDEA 提供了 View -> Show bytecode;Eclipse 也有类似工具;还有一些第三方工具,都可以。


JVM 对频繁调用方法进行哪些优化

如果代码需要经常被调用,那么即时编译就比解释运行要高效,因此编译器找出被调用最频繁的代码作为“热点代码”进行编译,并进行最大程度优化,即时编译器负责进行这个任务。

JVM的即使编译器(JIT)采用的优化手段包括:方法内联、逃逸分析、投机性优化等等;运行时优化常用手段包括:内存分配、动态绑定、偏斜锁等等。

HotSpot 基于计数器进行热点探测,对于热点代码进行即时编译、栈上替换。


Java 中什么是伪共享,怎么解决

https://www.cnblogs.com/javastack/p/9117134.html

CPU 缓存系统中是以缓存行(cache line)为单位存储的。目前主流的 CPU Cache 的 Cache Line 大小都是 64 Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

由于共享变量在 CPU 缓存中的存储是以缓存行为单位,一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,那么就会出现上诉的伪共享问题。直接带来的问题就是性能下降。

解决方法就是——独占缓存行,以空间换效率。

Java 8 中已经提供了官方的解决方案,Java 8 中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置 -XX:-RestrictContended 才会生效。

@sun.misc.Contended
public final static class VolatileLong {
    public volatile long value = 0L;
    //public long p1, p2, p3, p4, p5, p6;
Licensed under CC BY-NC-SA 4.0
comments powered by Disqus