目录

简单总结下最近关于jvm的知识,只保证自己能看懂。

前言

使用JVM的语言(java, scala)进行开发的工程师有必要对底层的JVM有所了解。

本文对jvm的分区不再赘述,主要讲以下几方面。

  • synchronized 底层实现

  • happens-before: 这是多线程并发的基础
  • volatile: volatile关键字的底层与使用注意事项
  • class load过程
  • ClassNotFoundException & NoClassDefFoundError
  • 各种OOM的含义
  • JVM 参数的使用

synchronized

多线程并发时,需要对线程进行同步,保证各个线程协调有序的进行。

在java中,有一个synchronized关键字,这个关键字可以用于修饰方法,修饰代码块。在java中,每个object都有一个隐藏的monitor, 因此可以对象都可以通过调用Object.wait, object.notify方法进行同步操作。而synchronized的实现也是基于monitor来实现,伪代码如下:

monitorenter
...
CodeBlock
...
monitorexit

happens-before

把happens-before定义为方法hb(a, b), 表示a的结果对b可见,这不一定代表a一定比b先执行,因为可能会进行指令重排优化。

  • 如果 x 和 y在同一个线程中,且在程序中顺序 x 先于 y,那么hb(x, y)
  • 一个object的构造方法 happens-before finalizer方法
  • 在一个synchronized代码块中,x 先于y,那么hb(x, y)
  • 如果hb(a, b)且hb(b, c),那么能够推导出hb(a, c)
  • 对于一个field的默认值构造happends-before其访问
  • 一个monitor的unlock happens-before 于该monitor后续的lock操作
  • 对于一个volatile的写hb与其读操作,后续会讲
  • 一个thread的start操作hb于其线程中的方法调用
  • 一个调用join方法的剩余所有操作,hb于被join中的其他执行操作
  • 一个对象的默认初始化操作hb于其他访问该对象的操作

happens-before对于单线程中的操作是可以保证可见性的,但是对于多线程的线程交互无法保证可见性。

在多线程中,每个线程都有独占的内存区域,如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本,线程对变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存中,这里必然有一个时间差,在这个时间差内,该线程对副本的操作,对于其他线程都是不可见的。

happens-before可以参考官方文档:https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5

volatile

volatile的英文意思是”挥发,不稳定的”,也就是敏感的,是java中的一个关键字,用于修饰变量,代表任何对此变量的操作都是在内存中进行,不会产生副本,以保证共享变量的可见性,局部组织了指令重排的发生

讲一下指令重排。

例如在单列模式中,我们通常使用双重检测来保证。

public class DoubleCheckLocking {
  private static Instance instance;
  
  public static Instance getInstance() {
    if (instance == null) {
      synchronized (DoubleCheckLocking.class) {
        if (instance == null) {
          instance = new Instance();
        }
      }
    }
    return instance
  }
}

上面代码中的一个操作, instance = new Instance()会被分解为下面三行伪代码,如下:

memory = allocate();    // 1、分配对象的内存空间
ctorInstance(memory);   // 2、初始化对象
instance = memory;      // 3、设置instance指向分配的内存地址

上面的三行代码的2,3步可能会被重排序,也就是说可能instance已经指向了分配的内存,但是该对象仍在初始化中。对于多线程来说,如果线程1,调用该单例构造,但是仍在构造中,而此时已经instance已经指向了内存地址,这样另一个线程2就会获得到一个还没有初始化完成的对象,这会造成错误。

而如果我们对instance使用volatile修饰private static volatile Instance instance;,禁止指令重排就可以避免这种情况的发生.

volatile解决的是多线程共享变量的可见性问题,类似于synchronized,但是不具备synchronized的互斥性。所以对volatile变量的操作并非都具有原子性。

例如

volatile int count;
count++;

由于一个++ 操作,包含读取,加1,存入操作,因此volatile不能保证其操作的原子性,可以使用AtomicLong等来替代,JDK8中推荐使用LongAdder类替代AtomicLong,它性能更好,有效地减少了乐观锁的重试次数。

因此,volatile只是保证共享变量的可见性,并非是一种同步方式,如果是并发写场景,那么一定会产生线程安全问题。

如果是一写多读的并发场景,那么使用volatile修饰变量很合适。在实际业务中,如果不能确定是否会并发写,那么保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量,所以volatile一定会使线程的执行速度变慢,所以要谨慎定义和使用volatile。

Class Load

此处简单描述一下。JVM中以下几种ClassLoader

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在$JAVA_HOME/lib目录中的。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载$JAVA_HOME/lib/ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  • 用户也可以自己实现类加载器。

两个类只有当类名与使用的类加载器都相同,才能说这两个类是相同的。

类加载时使用双亲委派模型,加载一个类,首先自顶向下进行判断是否包含这个类,也就是说,首先从Bootstrap ClassLoader进行加载,如果没有就从Extension ClassLoader进行加载,还没有就从Application ClassLoader进行加载,如果还没有就从用户自定义的ClassLoader进行加载。使用这种方法是为了加载的安全,例如在bootStrap ClassLoader加载的rt.jar里面的String类,如果用户自定义一个和String类名一样的类,那么通过双亲委派,就可以保证我们加载的是jdk中的String类, 这样可以保证放置一些库的类被篡改,更加安全。

如果有两个jar包,但是版本不同,里面的类名都是一致的,也就是可能产生jar包冲突的类,这样会由什么顺序会加载对应的类呢?

java -cp one.jar;two.jar MyMain

classLoader 会查找资源第一次出现的地方,这会通过classPath来寻找。如果A出现在one.jar中,那么就从One.jar中来加载类A。 如果java -cp two.jar;one.jar MyMain, 那么将会加载two.jar中的类A。

ClassNotFoundException & NoClassDefFoundError

java.lang.ClassNotFoundException代表这个类没有在classPath中找到。

java.lang.NoClassDefFoundError这个异常代表,JVM在其内部类定义数据结构中查找了类的定义,但没有找到对应的类。这和在classPath没有找到类有所不同。通常这代表我们之前尝试从classPath中加载这个类,但是由于某些原因加载失败,现在我们尝试再次去使用这个类(因此需要去load这个类由于上次load失败),但是我们不准备尝试去load它,因为我们之前load失败(因此推测出我们会再次失败)。前面的Failure可能是一个ClassNotFoundException 或者 ExceptionInInitializerError(静态代码块初始化失败) 或者其他问题。因此NoClassDefFoundError 不一定是因为classPath的问题,要看它前面失败的原因。

About OOM

JVM区划划分为:

  • 程序计数器 用于指向指令运行地址的
  • java虚拟机栈: 是线程私有的,每个方法执行会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
  • 本地方法栈: 和虚拟机栈类似,但方法是native方法。
  • Java堆: 用于存储对象
  • 方法区: 存储被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。在JDK1.7时被称为永久代,放在JVM中,可以通过设置PermSizeMaxPermSize来设置永久代大小,每次扩展永久代内存伴随full gc,而且超过最大永久代会造成内存溢出;jdk1.8中使用元空间代替永久代,减小gc,而且默认元空间不设上限,可以扩展。
  • 运行时常量池: Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和方法引用,这部分信息将在类加载后进入方法区的运行时常量池存放。在JDK1.6中运行时常量池是方法区的一部分;jdk1.7中,运行时常量池从方法区中挪出来, 单独存放在堆中;JDK1.8, 参数发生了改变,JVM使用MetaSpace代替了PermSpace, 元空间是放在堆外本地直接内存,可以设置MaxMetaSpace,而默认是无最大限制。
  • 直接内存: 堆外内存,可以使用sun.misc.unsafe类进行访问堆外内存,一些框架netty也是默认使用堆外内存进行缓存数据。

OutOfMemoryError异常分为:

  • 堆溢出
    • java.lang.OutOfMemoryError: Java heap space
      • 代表堆内存溢出了,需要合理设置和使用内存。
    • java.lang.OutOfMemoryError: Gc overhead limit exceeded
      • 系统大量的时间都在GC(98%)而回收的效果不明显(2% heap空间),就会抛出这个异常。实际这是一个JVM预判性的异常,也就是说抛出这个异常的时候没有真正的内存溢出。
  • 虚拟机栈和本地方法栈溢出
    • java.lang.StackOverflowError
      • 线程请求的栈深度大于虚拟机所允许的最大深度,通常是由于递归造成。
      • 在单线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当虚拟机栈内存无法分配都是抛出StackOverflowError.
    • java.lang.OutOfMemoryError: unable to create new native thread
      • 在建立多线程造成的内存溢出,可以通过减少每个线程栈容量来换取更多地线程数。
  • 方法区和运行时常量池溢出
    • JDK1.7 中有参数-XX:PermSize-XX:MaxPermSize来设置永久代大小,放在JVm中的非堆区域;jdk1.8中有参数-XX:MetaspaceSize-XX:MaxMetaspaceSize来设置元空间大小,放在本地直接内存,默认最大元空间是无限大的。
      • `java.lang.OutOfMemoryError:PermGen space/ Metaspace
        • 永久代/元空间溢出,可以调大MaxPermSize 和MaxMetaspaceSize (默认无上限,不设置也行).
  • 本地直接内存溢出
    • java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(native Method)
      • 可以调大 -XX:MaxDirectMemorySize调大最大直接内存容量
      • netty中会默认使用本地直接内存来缓存数据,可以设置-Dio.netty.noPreferDirect=true -Dio.netty.recycler.maxCapacity=0 -Dio.netty.noUnsafe=true来避免使用堆外内存来避免。

JVM Args

  • -Xms1024m -X 表示是JVM参数, ms是memory start
  • -Xmx1024m mx是memory max, 线上生产环境建议Xms和Xmx设置一样,避免调整堆大小带来的压力。
  • -Xss256k ss是Stack Space,是每个线程栈空间大小,因此,这个数值影响可以创建的最大线程数量
  • -XX:NewRatio=4:设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:4。
  • -XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区与1个Eden区的比值为2:4,即1个Survivor区占整个年轻代大小的1/6。
  • -XX:-PrintGCDetails:每次GC时打印详细信息。
  • -Dio.netty.noPreferDirect=true
    • 格式: java -D<name>=<value>
    • 作用: 设置一个系统属性值,如果这个value是一个包含空格的String,那么需要使用双引号包裹这个String.
    • 获取设置的值: System.getProperty(“name”)