Java Advanced

11 minute read

Published:

This is my personal notes for Java Advanced.


JVM 堆栈

  • 基本概念

    寄存器:最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制.

    栈:存放基本类型的变量数据和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)或者常量池中(字符串常量对象存放在常量池中。)

    堆:存放所有new出来的对象。

    静态域:存放静态成员(static定义的)

    常量池:存放字符串常量和基本类型常量(public static final)。

    非RAM存储:硬盘等永久存储空间

    从堆和栈的功能和作用来通俗的比较,堆主要用来存放对象的,栈主要是用来执行程序的.

  • JVM 堆栈

    JAVA的JVM的内存可分为3个区:堆(heap)、栈(stack)和方法区(method)

    • 栈区:

      每个线程包含一个栈区,栈中只保存方法中(不包括对象的成员变量)的基础数据类型和自定义对象的引用(不是对象),对象都存放在堆区中, 每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。

    • 堆区:

      存储的全部是对象实例,每个对象都包含一个与之对应的class的信息(class信息存放在方法区)。jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身,几乎所有的对象实例和数组都在堆中分配。 在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。

    • 方法区:

      又叫静态区,跟堆一样,被所有的线程共享。它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


Java垃圾回收(GC)

  • 1 确定对象是否可以回收

    • a 引用计数算法:判断对象的引用数量

      堆中的每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个引用变量,该对象实例的引用计数设置为 1。当任何其它变量被赋值为这个对象的引用时,对象实例的引用计数加 1(a = b,则b引用的对象实例的计数器加 1,但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数减 1。特别地,当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器均减 1。任何引用计数为0的对象实例可以被当作垃圾收集。

    • b 可达性分析算法:判断对象的引用链是否可达

      可达性分析算法是通过判断对象的引用链是否可达来决定对象是否可以被回收。

      把所有的引用关系看作一张图,通过GC Roots作为起始点,从这些节点开始向下搜索,从GC Roots不可到达的对象是不可用的。

      GC Roots包括:
      (1) 虚拟机栈(栈帧中的局部变量表)中引用的对象 (局部变量)
      (2) 方法区中类静态属性引用的对象 (静态变量)
      (3) 方法区中常量引用的对象 (常量)
      (4) 本地方法栈中Native方法引用的对象
      (5) 全局变量 不属于 GC Root是因为它存储在堆中, 属于对象, 随着对象消亡

      avatar

  • 2 垃圾回收算法

    • a 标记清除算法
      用freeList进行空闲内存的记录, 该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收

      缺点:
      (1) 标记和清除两个过程的效率都不高;
      (2) 用freeList不需要移动对象, 但是会存在内存碎片化的问题, 比如5k的内存放入了一个4.9k的对象

    • b 标记整理算法
      用heap pointer进行空闲内存的记录, 该算法对集合进行扫描, 对不存活的对象进行压缩, 分配时直接移动heap pointer进行对应size内存的分配,不存在碎片化问题, 适用于老年代场景, 不需要很多的压缩清理

      标记整理算法与标记清除算法最显著的区别是:标记清除算法不进行对象的移动,并且仅对不存活的对象进行处理;而标记整理算法会将所有的存活对象移动到一端,并对不存活对象进行压缩清理,因此其不会产生内存碎片。但是其内存reclaim的代价要更高, 因为要移动存活对象。

    • c 复制算法 将内存划分成2块相同size的heap, 当一个heap满的时候, 将其中的存活对象复制到另一个heap中, 清空当前heap。同样不存在碎片化问题, 适用于新生代, 如果对象存活率太高, 就会频繁的溢出复制, 效率降低

    • d 分代收集算法 对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。

      当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java堆内存一般可以分为新生代、老年代和永久代三个模块,如下图所示:

      avatar

  • 3 垃圾回收类型

    当在堆中产生了一个数组或者对象时,可以在栈中定义一个特殊的变量,让栈中的这个变量的取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或者对象,引用变量就相当于是为数组或者对象起的一个名称。引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域之外后被释放。 所以栈不需要特别的垃圾回收机制,到达作用域之外内存自动释放可以重新使用。

    而数组和对象本身在堆中分配,即使程序运行到使用new产生数组或者对象的语句所在的代码块之外,数组和对象本身占据的内存不会被释放,数组和对象在没有引用变量指向它的时候,才变为垃圾,不能在被使用,但仍然占据内存空间不放,在随后的一个不确定的时间被垃圾回收器收走(释放掉)。

    由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。垃圾回收有两种类型,Minor GCFull GC

    • a Minor GC:对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收能尽快完成。

    • b Full GC:也叫 Major GC,对整个堆进行回收,包括新生代和老年代。由于Full GC需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数,导致Full GC的原因包括:老年代被写满、永久代(Perm)被写满和System.gc()被显式调用等。

  • 4 内存分配策略

    • a 对象优先在 Eden 分配

      大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

    • b 大对象直接进入老年代

      大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

      经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

      -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

    • c 长期存活的对象进入老年代

      为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

      -XX:MaxTenuringThreshold 用来定义年龄的阈值。

    • d 动态对象年龄判定

      虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

  • 5 为什么新生代内存需要有两个Survivor区

    avatar

    • a 为什么要有Survivor区

      减少被送到老年代的对象

      减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC(16次复制)还能在新生代中存活的对象,才会被送到老年代。

    • b 为什么要有两个Survivor区

      避免内存碎片化

      建立两块Survivor区,Eden经历Minor GC时,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。如果没有复制, s中的内存将会碎片化, 因为有一部分对象会消亡。

  • 6 总结

    avatar


Java 单例模式

单例模式核心在于为整个系统提供一个唯一的实例,为整个系统提供一个全局访问点。单例模式从实现上可以分为饿汉式单例懒汉式单例两种,前者天生就是线程安全的,后者则需要考虑线程安全性,常见的线程安全的懒汉式单例的实现有内部类式和双重检查式两种。下面给出单例模式几种常见的形式:

(1) 饿汉式单例

// 饿汉式单例
public class Singleton1 {

    // 指向自己实例的私有静态引用,主动创建
    private static Singleton1 singleton1 = new Singleton1();

    // 私有的构造方法
    private Singleton1(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton1 getSingleton1(){
        return singleton1;
    }
}

用空间换时间,天生线程安全

(2) 懒汉式单例 (线程不安全)

// 懒汉式单例
public class Singleton2 {

    // 指向自己实例的私有静态引用
    private static Singleton2 singleton2;

    // 私有的构造方法
    private Singleton2(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton2 getSingleton2(){
        // 被动创建,在真正需要使用时才去创建
        if (singleton2 == null) {
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}

(3) 线程安全的懒汉式单例 —— 加同步


public class Singleton {

    // volatile: 防止指令重排序
    private volatile static Singleton instance;

    private Singleton() {

    }

    public static synchronized Singleton getInstance() {  
        if (instance == null) {    
            instance = new Singleton();  
        }    
        return instance;  
    }  

}

缺点: 在方法调用上加了同步虽然线程安全了但是每次都要同步会影响性能毕竟99%的情况下是不需要同步的

(4) 线程安全的懒汉式单例 —— 双重检查方式

public class Singleton {

    // volatile: 防止指令重排序
    private volatile static Singleton instance;

    private Singleton() {

    }

    public static Singleton getInstance() {
        // 第一次检查
        if(instance == null){
            // 只在最初几次会进入该同步块,提高效率
            synchronized(Singleton.class){
                // 第二次检查
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

DCL((Double Check Lock))模式的优点就是,在getInstance中做了两次null检查,确保了只有第一次调用单例的时候才会做同步,这样是线程安全的,同时避免了每次都同步的性能损耗。但是,由于jvm存在乱序执行功能,DCL也会出现线程不安全的情况。具体分析如下:

INSTANCE  = new SingleTon(); 

1.在堆内存开辟内存空间。
2.在堆内存中实例化SingleTon里面的各个参数。
3.把对象指向堆内存空间。

由于jvm存在乱序执行功能,所以可能在2还没执行时就先执行了3,如果此时再被切换到线程B上,由于执行了3,INSTANCE 已经非空了,会被直接拿出来用,这样的话,就会出现异常。这个就是著名的DCL失效问题。

不过在JDK1.5之后,官方也发现了这个问题,故而具体化了volatile,即在JDK1.6及以后,只要定义为private volatile static SingleTon instance, 就可解决DCL失效问题。volatile确保INSTANCE每次均在主内存中读取,这样虽然会牺牲一点效率,但也无伤大雅。

(5) 线程安全的懒汉式单例 —— 内部类方式 (推荐)

public class Singleton {
    //静态私有内部类
    private static class InnerClass {
        private static final Singleton instance = new Singleton();
    }

    private Singleton(){

    }

    public static Singleton getInstance(){
        return InnerClass.instance;
    }
}

静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化instance,又因为JVM可以保证类初始化的线程安全, 所以这种方法是线程安全的,且也能保证单例的唯一性,同时也延迟了单例的实例化。

内部类模式类似饿汉模式, 不过是加载内部类时才开启饿汉模式。

关于JVM保证类初始化的线程安全,《深入理解JAVA虚拟机》中,有这么一句话:

虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞。需要注意的是,其他线程虽然会被阻塞,但如果执行()方法后,其他线程唤醒之后不会再次进入()方法。同一个加载器下,一个类型只会初始化一次。在实际应用中,这种阻塞往往是很隐蔽的。

缺点: 因为是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如Context这种参数, DCL使用new Singleton()的方式在getInstance中创建, 可以传参。


JVM 调优

JVM 调优的主要目标是使系统具有 高吞吐 、低停顿 的特点,其优化可以通过调整JVM的参数并通过相应的工具监测效果进行, 可以调整的参数主要包括:

  • JVM 参数

    • a -Xmx
      Java heap的最大值,默认值为物理内存的1/4,最佳设值应该视物理内存大小及计算机内其他内存开销而定;
    • b -Xms
      Java heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
    • c -Xmn
      Java heap 中 Young区(新生代)大小
    • d -Xss
      每个线程的stack大小

    IDEA中的JVM默认参数(vmoptions中可查看)

      -Xms128m
      -Xmx1024m
      -XX:ReservedCodeCacheSize=240m
      -XX:+UseConcMarkSweepGC
      -XX:SoftRefLRUPolicyMSPerMB=50
      -ea
      -XX:CICompilerCount=2
      -Dsun.io.useCanonPrefixCache=false
      -Djava.net.preferIPv4Stack=true
    
  • 调优工具

    • Jconsole

      JConsole 可以直接从IDEA terminal中调用, 其可以监控 Java 应用程序性能和跟踪 Java 中的代码

    • Visual VM

      IDEA需要在Plugin中安装Visual VM

    • (terminal命令) jps

      显示所有进程的pid

    • (terminal命令) jstack [-l][-e]

      可以查看某个Java进程内的线程堆栈信息,主要用于线程Dump分析


类的生命周期及其初始化时机

类的生命周期主要包括加载、链接、初始化、使用和卸载五个阶段,如下图所示:

其中,虚拟机规范指明 有且只有 五种情况必须立即对类进行初始化,包括:

  • 1 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时

    注意,newarray指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String[]只会直接触发String[]类的初始化,也就是触发对类[Ljava.lang.String的初始化,而直接不会触发String类的初始化时,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:

    • 使用new关键字实例化对象的时候;

    • 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;

    • 调用一个类的静态方法的时候。

  • 2 对类进行反射调用时

    使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

  • 3 初始化子类时

    当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  • 4 虚拟机启动时

    当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  • 5 当使用jdk1.7动态语言支持时

    如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic, REF_putstatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化


类加载过程中各阶段的作用

  • 1、 加载(Loading)

    • (1). 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);

    • (2). 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;

    • (3). 在内存中(对于HotSpot虚拟就而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;

  • 2、 验证(Verification):

    验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 3、准备(Preparation):

    准备阶段是正式为类变量(static 成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。

  • 4、解析(Resolution):

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 5、初始化(Initialization):

    初始化阶段是执行类构造器()方法的过程。虚拟机会保证一个类的类构造器()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器(),其他线程都需要阻塞等待,直到活动线程执行()方法完毕。特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。


内部类

内部类指的是在一个类的内部所定义的类,类名不需要和源文件名相同。在Java中,内部类是一个编译时的概念,一旦编译成功,内部类和外部类就会成为两个完全不同的类,共有四种类型:

  • 类型

    • 成员内部类:

      成员内部类是外围类的一个成员,是依附于外围类的,所以,只有先创建了外围类对象才能够创建内部类对象。也正是由于这个原因,成员内部类也不能含有 static 的变量和方法;

    • 静态内部类:

      静态内部类,就是修饰为static的内部类,该内部类对象不依赖于外部类对象,就是说我们可以直接创建内部类对象,但其只可以直接访问外部类的所有静态成员和静态方法;

    • 局部内部类:

      局部内部类和成员内部类一样被编译,只是它的作用域发生了改变,它只能在该方法和属性中被使用,出了该方法和属性就会失效;

    • 匿名内部类:

      定义匿名内部类的前提是,内部类必须要继承一个类或者实现接口,格式为 new 父类或者接口(){定义子类的内容(如函数等)}。也就是说,匿名内部类最终提供给我们的是一个 匿名子类的对象。

  • 功能

    • 间接实现多继承

          //父类Example1
          public class Example1 {
              public String name() {
                  return "rico";
              }
          }
      
          //父类Example2
          public class Example2 {
              public int age() {
                  return 25;
              }
          }
      
          //实现多重继承的效果
          public class MainExample {
      
              //内部类Test1继承类Example1
              private class Test1 extends Example1 {
                  public String name() {
                      return super.name();
                  }
              }
      
              //内部类Test2继承类Example2
              private class Test2 extends Example2 {
                  public int age() {
                      return super.age();
                  }
              }
      
              public String name() {
                  return new Test1().name();
              }
      
              public int age() {
                  return new Test2().age();
              }
      
              public static void main(String args[]) {
                  MainExample mexam = new MainExample();
                  System.out.println("姓名:" + mexam.name());
                  System.out.println("年龄:" + mexam.age());
              }
          }
      
          // Output:
          // 姓名:rico
          // 年龄:25
      

      MainExample分别继承了Example1和Example2的方法

    • 实现隐藏

      一般的非内部类,是不允许有 private 与 protected 权限的,但内部类可以。这样可以将某些功能类放进外部类中,构成一个供外部类调用的模块,而外部是看不到的。


对象的创建过程

在Java中,创建一个对象常常需要经历如下几个过程:

父类的类构造器() -> 子类的类构造器() -> 父类的实例构造器(成员变量和实例代码块,父类的构造函数) -> 子类的实例构造器(成员变量和实例代码块,子类的构造函数)。

其中,类构造器()由静态变量和静态语句块组成,而类的实例构造器()由类的实例变量/语句块以及其构造函数组成。


Java异常机制

  • 异常类型

    Java体系中异常的组织分类如下图所示,所有异常类型的根类为 Throwable,具体包括两大类:Error 与 Exception。其中,Error是指程序无法处理的错误,表示运行应用程序中较严重问题;Exception是指程序本身可以处理的错误,具体可分为运行时异常(派生于 RuntimeException 的异常) 和 其他异常。

    此外,从异常是否必须需要被处理的角度来看,异常又可分为不受检查异常和受检查异常两种情况:

    不受检查异常:派生于 Error 或 RuntimeException 的所有异常;

    受检查异常:除去不受检查异常的所有异常。

    avatar

  • finally子句

    下面着重介绍一下try catch中的finally子句,注意两点:

    • a 进入try, finally一定会执行

    • b finally总是会在控制转移语句之前执行(如return、break、throw和continue等)

    举例:

    (1) try执行,finally必然执行

    
        public class Test {
            
          public static void main(String[] args) {  
              try {  
                  System.out.println("try block");  
                  return ;  
              } finally {  
                  System.out.println("finally block");  
              }  
          }  
      }
    
        // Output:
        // try block 
        // finally block
    
    

    (2) finally在控制转移语句return之前执行

    
        public class Test { 
            public static void main(String[] args) {  
                System.out.println("reture value of test() : " + test()); 
            } 
    
            public static int test(){ 
                int i = 1; 
    
                try {  
                    System.out.println("try block");  
                    i = 1 / 0; 
                    return 1;  
                }catch (Exception e){ 
                    System.out.println("exception block"); 
                    return 2; 
                }finally {  
                    System.out.println("finally block");  
                } 
            } 
        }
    
        // Output:
        // try block 
        // exception block 
        // finally block 
        // reture value of test() : 2
    
    

    (3) finally在控制转移语句throw之前执行

    
        public class ExceptionSilencer { 
            public static void main(String[] args) { 
                try { 
                    throw new RuntimeException(); 
                } finally { 
                    // Using ‘return’ inside the finally block 
                    // will silence any thrown exception. 
                    return; 
                } 
            } 
        }
    
        // Output:
        // 无exception throw
    

七大设计原则

  • 单一职责原则:高内聚,一个类只做它该做的事情;

  • 接口隔离原则: 接口小而专,避免大而全;

  • 依赖倒置原则:依赖抽象而非实现,面向接口编程;

  • 里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能;

  • 开闭原则:Open for Extension, Closed for Modification,例如 AOP,代理模式,适配器模式就是其经典应用;

  • 迪米特法则:高内聚,低耦合;


equals, hashCode, ==

  • ==
    用于判断两个对象是否为同一个对象或者两基本类型的值是否相等;

  • equals
    用于判断两个对象内容是否相同;

  • hashCode
    是一个对象的 消息摘要函数,一种 压缩映射,其一般与equals()方法同时重写;若不重写hashCode方法,默认使用Object类的hashCode方法,该方法是一个本地方法,由 Object 类定义的 hashCode 方法会针对不同的对象返回不同的整数 (对两个内容相同的对象hash会得到不同的整数)。


    HashSet set1 = new HashSet();
    set1.add("1");
    set1.add("1");

    HashSet set2 = new HashSet();
    Person p1 = new Person("1");
    Person p2 = new Person("1");
    set2.add(p1);
    set2.add(p2);

    // 结果:
    // set1 1个元素
    // set2 2个元素

以上现象产生的原因是Person没有重写Person的equal和HashCode方法,导致对比两个对象的地址, 被视为2个不同的元素, 比较过程: 先调用HashCode比较HashCode是否相同, 如果相同再调用equals方法, 所以重写时需要equals和hashCode同时重写


   static class Person {
        private String age;

        Person(String age) {
            this.age = age;
        }
        
        //重写equals()
        @Override
        public boolean equals(Object obj) {
            if (obj == null || !(obj instanceof Person)) {
                return false;
            }
            //地址相同必相等
            if (obj == this) {
                return true;
            }
            Person person = (Person) obj;
            //地址不同比较值是否相同
            return person.age.equals(this.age);
        }

        //重写hashCode()
        @Override
        public int hashCode() {
            return Objects.hash(age);
        }
   }


Java 锁机制

avatar

下面重点介绍synchronized的底层原理, 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

为什么Synchronized能实现线程同步? 首先需要了解两个重要的概念:“Java对象头”、“Monitor”。

  • Java对象头

    synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

    以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

    Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

    Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • Monitor

    Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

    Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

如果我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

锁状态存储内容存储内容
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
  • 无锁

    无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

    无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

  • 偏向锁

    偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

    在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

    当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

    偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

  • 轻量级锁

    是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

    在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

    拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

    如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

    如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

    若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

  • 重量级锁

    升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

总之, 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。


Java synchronized 和 Lock的区别

总结来说,Lock与synchronized有以下区别:

  • Lock是一个接口,而synchronized是关键字。
  • synchronized会自动释放锁,而Lock必须手动释放锁。
  • synchronized能锁住类、方法和代码块,而Lock是块范围内的。
  • Lock可以让等待锁的线程响应中断,而synchronized不会,线程会一直等待下去。
  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
  • Lock能提高多个线程读操作的效率。

线程同步和互斥

  • 线程同步

    线程同步是指线程按预定的先后次序进行运行。如:你说完,我再说。这里的同步并不是指同时进行,应是指协同、协助、互相配合。线程同步是指多线程通过特定的设置(如互斥量,事件对象,临界区)来控制线程之间的执行顺序(即所谓的同步)也可以说是在线程之间通过同步建立起执行顺序的关系,如果没有同步,那线程之间是各自运行各自的!

  • Java中实现线程同步的方法

    • 1 Synchronized: 是一个关键字, 可以锁住类, 方法, 代码块, 自动释放锁

    • 2 Lock: 是一个接口, 可以锁住代码块, 需要手动用lock.unlock()释放锁

    • 3 Volatile: 将修改立即刷入主存, 保证了可见性和一定的有序性, 使得线程间得以同步

  • 线程互斥

    线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。


Java 多线程编程

Java中实现多线程的几种方式:

1.继承Thread类,重写run方法

缺点是Java是单继承, 这样写会导致不能继承其他的类, 不够灵活。


class MyThread extends Thread {

    public void run() {
        while (true){
            System.out.println(Thread.currentThread().getName() + " is running");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class main{

    public static void main(String[] args) {
        MyThread myThread1 = new MyThread();
        MyThread myThread2 = new MyThread();
        myThread1.start();
        myThread2.start();
    }

}

2.实现Runnable接口,重写run方法,用Thread创建线程, 实例化一个实现Runnable接口的实现类的对象传给Thread


class MyThread implements Runnable {

     public void run() {
         while (true){
             System.out.println(Thread.currentThread().getName() + " is running");
             try {
                 Thread.sleep(1000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }

}

class main{

    public static void main(String[] args) {
        // 首先实例化一个Thread,并传给Thread
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread);
        Thread thread2 = new Thread(myThread);
        thread1.start();
        thread2.start();
    }

}

3.实现Callable接口, 通过Callable和FutureTask创建线程


class MyThread implements Callable {

    public Object call() {
        while (true){
            System.out.println(Thread.currentThread().getName()+"-->我是通过实现Callable接口通过FutureTask包装器来实现的线程");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class main{

    public static void main(String[] args) throws InterruptedException {

        Callable callable1 = new MyThread();
        Callable callable2 = new MyThread();

        FutureTask task1 = new FutureTask(callable1);
        FutureTask task2 = new FutureTask(callable2);

        Thread thread1 = new Thread(task1);
        Thread thread2 = new Thread(task2);

        thread1.start();
        thread2.start();
    }

}

4.通过线程池创建线程

线程池可以设置最大线程数量, 若加入线程大于最大数量, 其他线程将处于等待状态


class MyThread implements Runnable {

    private int token = 2;
    public void run() {
        while (token>0){
            System.out.println("通过线程池方式创建的线程:" + Thread.currentThread().getName() + " ");
            System.out.println("token is " + token);
            token--;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


class main{
    
    public static void main(String[] args) throws InterruptedException {

        // 参数为线程池最大容量, 每次最多同时执行的线程数量, 其他线程已加入,处于等待状态
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        for(int i = 0; i<10; i++)
        {
            MyThread thread = new MyThread();
            executorService.execute(thread);
        }
        //关闭线程池
        executorService.shutdown();
    }

}


Java 线程状态 以及 wait() 和 sleep()的区别

Java 线程状态:

avatar

  • NEW:

    新创建了一个线程对象,但还没有调用start()方法。

    实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

  • RUNNABLE:

    Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为 runnable。

    线程对象创建后调用start()方法进入ready状态。但是还没有running, 除了start()方法让线程进入ready状态,下面三种情况也可以:

    • 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。

    • 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。

    • 锁池里的线程拿到对象锁后,进入就绪状态。

  • BLOCKED:

    表示线程阻塞于锁。

    是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

  • WAITING:

    进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。

    处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

  • TIMED_WAITING:

    该状态不同于WAITING,它可以在指定的时间后自行返回。

    处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

  • TERMINATED:

    表示该线程已经执行完毕。

    当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。

    在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

sleep与Wait的区别

  • sleep是Thread类的方法, wait是Object类中定义的方法

  • sleep可以在任何地方使用; wait只能在synchronize方法或者synchronize块中使用

  • Thread.sleep只会让出CPU,不会导致锁行为的改变;Object.wait不仅让出CPU,还会释放已经占有的同步资源锁

总结起来就是:

sleep方法是Thread类里面的,主要的意义就是让当前线程让出CPU给其他的线程,但是不会释放对象锁资源以及监控的状态,当指定的时间到了之后又会自动恢复运行状态。

wait方法是Object类里面的,主要的意义就是让线程放弃当前的对象的锁,进入等待此对象的等待锁定池,只有针对此对象调动notify方法后本线程才能够进入对象锁定池准备获取对象锁进入运行状态。


Java I/O

  • 磁盘操作

    File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。

    递归地列出一个目录下所有文件:

    
      public static void listAllFiles(File dir) {
        if (dir == null || !dir.exists()) {
            return;
        }
        if (dir.isFile()) {
            System.out.println(dir.getName());
            return;
        }
        for (File file : dir.listFiles()) {
            listAllFiles(file);
        }
      }
    
    
  • 字节操作

    • 实现文件复制

      
          public static void copyFile(String src, String dist) throws IOException {
              FileInputStream in = new FileInputStream(src);
              FileOutputStream out = new FileOutputStream(dist);
      
              byte[] buffer = new byte[20 * 1024];
              int cnt;
      
              // read() 最多读取 buffer.length 个字节
              // 返回的是实际读取的个数
              // 返回 -1 的时候表示读到 eof,即文件尾
              while ((cnt = in.read(buffer, 0, buffer.length)) != -1) {
                  out.write(buffer, 0, cnt);
              }
      
              in.close();
              out.close();
          }
      
      
    • 装饰者模式

      Java I/O 使用了装饰者模式来实现。以 InputStream 为例,

      • InputStream 是抽象组件;
      • FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
      • FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。
      • DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。

      avatar

      实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。

        FileInputStream fileInputStream = new FileInputStream(filePath);
        BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
      
  • 字符操作
    • 编码与解码

      编码就是字符->字节,而解码是字节->字符。

      如果编码和解码过程使用不同的编码方式那么就出现了乱码。

      • GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
      • UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
      • UTF-16be 编码中,中文字符和英文字符都占 2 个字节。
      • UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。

      Java 的内存编码使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。

    • String 的编码方式

      String 可以看成一个字符序列,可以指定一个编码方式将它编码为字节序列,也可以指定一个编码方式将一个字节序列解码为 String。

        String str1 = "中文";
        byte[] bytes = str1.getBytes("UTF-8");
        String str2 = new String(bytes, "UTF-8");
        System.out.println(str2);
      

      getBytes() 的默认编码方式一般为 UTF-8, 而不是UTF-16be

    • Reader 与 Writer

      不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。

      InputStreamReader 实现从字节流解码成字符流;
      OutputStreamWriter 实现字符流编码成为字节流。

      逐行读取文本文件内容

      
        public static void readFileContent(String filePath) throws IOException {
      
            FileReader fileReader = new FileReader(filePath);
            BufferedReader bufferedReader = new BufferedReader(fileReader);
      
            String line;
            while ((line = bufferedReader.readLine()) != null) {
                System.out.println(line);
            }
      
            // 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
            // 在调用 BufferedReader 的 close() 方法时会去调用 Reader 的 close() 方法
            // 因此只要一个 close() 调用即可
            bufferedReader.close();
        }
      
      
  • 对象操作

    对象序列化就是将一个对象转换成字节,方便存储和传输。

    序列化:ObjectOutputStream.writeObject() 反序列化:ObjectInputStream.readObject()

    不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态

    序列化的类需要实现 Serializable 接口,它只是一个标准,没有任何方法需要实现,但是如果不去实现它的话而进行序列化,会抛出异常。

    将一个对象序列化并存储到文件, 然后读取并反序列化

        public static void main(String[] args) throws IOException, ClassNotFoundException {
    
            A a1 = new A(123, "abc");
            String objectFile = "file/a1";
    
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
            objectOutputStream.writeObject(a1);
            objectOutputStream.close();
    
            ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
            A a2 = (A) objectInputStream.readObject();
            objectInputStream.close();
            System.out.println(a2);
        }
    
        private static class A implements Serializable {
    
            private int x;
            private String y;
    
            A(int x, String y) {
                this.x = x;
                this.y = y;
            }
    
            @Override
            public String toString() {
                return "x = " + x + "  " + "y = " + y;
            }
        }
    

    transient 关键字可以使一些属性不会被序列化。

  • 网络操作

    Java 网络支持

    • InetAddress:用于表示网络上的硬件资源,即 IP 地址;
    • URL:统一资源定位符;
    • Sockets:使用 TCP 协议实现网络通信;
    • Datagram:使用 UDP 协议实现网络通信。

    InetAddress
    没有公有的构造函数,只能通过静态方法来创建实例。

      InetAddress.getByName(String host);
      InetAddress.getByAddress(byte[] address);
    

    URL
    可以直接从 URL 中读取字节流数据。

    
      public static void main(String[] args) throws IOException {
    
          URL url = new URL("http://www.baidu.com");
    
          /* 字节流 */
          InputStream is = url.openStream();
    
          /* 字符流 */
          InputStreamReader isr = new InputStreamReader(is, "utf-8");
    
          /* 提供缓存功能 */
          BufferedReader br = new BufferedReader(isr);
    
          String line;
          while ((line = br.readLine()) != null) {
              System.out.println(line);
          }
    
          br.close();
      }
    
    

    Sockets

    • ServerSocket:服务器端类
    • Socket:客户端类
    • 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。

      avatar

    Datagram

    • DatagramSocket:通信类
    • DatagramPacket:数据包类