Java底层

从更底层的角度了解Java。

集合(Collections)

Java数组

所谓数组,是相同数据类型的元素按一定顺序排列的集合。Java中一般来说有三种声明数组的方式:

// method1
char[] array1 = {'a', 'b', 'c'};

// method2
char[] array2 = new char[]{'a', 'b', 'c'};

// method3
char[] array3 = new char[3];
array[0] = 'a';
array[1] = 'b';
array[2] = 'c';

这三种写法看似有明显的区别,然而经过反编译之后会发现,它们都被转化成了同一种写法。也就是说,这三种写法的实质操作是一样的,并且每一个数组都是按顺序排列在堆内存中,这使得我们可以通过下标的方式来直接访问数组里的元素。


看完一维数组,二维数组基本也弄懂了。在堆内存中,与其说是“二维数组”,倒不如说是“二级数组”,第一维数组更像是索引,指向若干个随机分布的第二维数组起始地址:

2DArray.png


最后如果更加详细地查看每个数组的属性和方法,会发现Object类的方法它基本都有,并且还额外多了一些很有用的属性(例如length)。因此,在Java层面,完全可以把数组当成对象看待。

HashMap

底层实现

  • 结构 来看源代码中HashMap的实现:
    transient Node<K,V>[] table;
    static class Node<K,V> implements Map.Entry<K,V> {
      final int hash;
      final K key;
      V value;
      Node<K,V> next;
    }
    
  • 可以看到 HashMap 内部存储使用了一个 Node 数组(默认大小是16),而 Node 类包含一个类型为 Node 的 next 的变量,也就是相当于一个链表。所有根据 hash 值计算的 bucket 一样的 key 会存储到同一个链表里。 从jdk 1.8开始,中如果 hash 值相同的 key 数量大于指定值(默认是8)时,会使用红黑树来代替链表,这会将get()方法的性能从O(n)提高到O(logn)。
  • 插入元素 在jdk 1.7以及之前,HashMap插入元素到单链表时,采用头插入法,在扩容时需要重新计算哈希值和索引位置,会改变链表中元素原本的顺序,以至于在并发场景下有可能导致链表成环。而从jdk1.8开始,HashMap采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。 元素插入时,流程图如下:

hashmap-put.png

hash算法

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。

JDK 1.8开始,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h »> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。

自动扩容机制

HashMap 内部的 Node 数组默认的大小是16。

假设有100万个元素,那么最好的情况下每个 hash 桶里都有62500个元素,这时get(), put(), remove()等方法效率都会降低。为了解决这个问题,HashMap 提供了自动扩容机制,当元素个数达到数组大小 loadFactor 后会扩大数组的大小,在默认情况下,数组大小为16,loadFactor 为0.75,也就是说当 HashMap 中的元素超过160.75=12时,会把数组大小扩展为216=32,并且计算每个元素在新数组中的位置。

使用2的幂次,有两个主要原因:

  • 方便hash算法中的位运算
  • 扩容后元素均匀分布。元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。在扩充HashMap的时候,不需要重新计算hash。假如table扩容后的长度为n,只需要把n-1和原来的hash值按位与,看结果hash值高位新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap”。由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

线程安全性

不安全。考虑以下两个场景:

  • 如果多个线程同时使用put方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程的 put 的数据被覆盖。
  • 如果多个线程同时检测到元素个数超过数组大小 * loadFactor ,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失。最糟糕的是,并发甚至可能导致死循环,因为多线程会导致 HashMap 的 Node 链表形成环形数据结构,Node 的 next 节点永远不为空。

有三种常见解决方式:

  • Hashtable 源码中是使用 synchronized 来保证线程安全的,当一个线程访问 HashTable 的同步方法时,其他线程如果也要访问同步方法,会被阻塞住。这种方法安全,但是效率很低
  • SynchronizedMap 同步整个对象,每一次的读写操作都需要加锁,对整个对象加锁会极大降低性能
  • ConcurrentHashMap,性能比起前两个优越很多
    • 基础数据结构与HashMap类似,JDK1.7及以前是 数组 + 链表,JDK1.8开始是 数组 + 链表 + 红黑树
    • JDK 1.7以及之前,ConcurrentHashMap依靠HashEntry[] + Segment[] + ReentrantLock实现了分段锁,也就是将Map分割成了不同的部分,每个Segment对应若干个HashEntry,在执行更新操作时每个Segment使用单独的ReentrantLock。根据默认的并发级别(concurrency level,也就是Segment数组的长度),Map被分割成16个部分,并且由不同的锁控制。这意味着同时可以有16个写线程操作Map。
      • 进行put操作时,ConcurrentHashMap会尝试自旋获取锁,如果获取失败肯定就有其他线程存在竞争;如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功
    • JDK 1.8开始,由于Segment分段锁的诸多缺点(多个分段锁浪费内存空间,实际生产环境中插入新元素竞争同一个锁的概率非常小,GC效率低),ConcurrentHashMap改用了 CAS(Compare And Swap,当且仅当旧预期值和当前内存值相同时,写入新内存值) + synchronized 来保证并发安全性,此外将HashEntry改为了Node,但作用基本相同。
      • 进行put操作时,通过hashCode定位出Node,如果为空就用CAS机制尝试写入,写入失败就自旋保证成功;也有可能通过计算出来的hashCode值是MOVED(-1),需要扩容;如果这些都不满足,就需要用synchronized锁保证写入数据。
    • 允许并发的读操作,JDK 1.7以及之前只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上;JDK 1.8之后也是通过hashCode寻址,看元素具体是在桶上、链表上还是红黑树上决定对应的遍历获取方式。读操作是非常高效的,因为整个过程都不需要加锁,这要归功于volatile关键字,使用它会强制将修改的值立即写入主内存,当线程2进行修改时,会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效),这时线程1如果需要读取变量的值时,只能去主存读取最新值。ConcurrentHashMap中,volatile修饰了每个Node的val值以及next指针,使得线程之间具有了可见性
    • 如果一个线程在遍历的同时,另一个线程尝试进行修改,ConcurrentHashMap不会抛出ConcurrentModificationException并终止遍历(快速失败)。迭代前,ConcurrentHashMap先复制了原有集合内容,并在拷贝的集合上进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到(安全失败)

ArrayList

特点

ArrayList是容量能动态增长的动态数组。

  • 继承了AbstractList,实现了List。它是一个数组队列,提供了相关的添加、删除、修改、遍历等功能
  • 实现了RandmoAccess接口,提供了随机访问功能,因此可以通过元素的序号快速获取元素对象
  • 实现了Cloneable接口,能被克隆
  • 实现java.io.Serializable接口,这意味着ArrayList支持序列化,能通过序列化去传输

底层实现:Object[]数组 + size

  • elementData,实质是”Object[]类型的数组”,它保存了添加到ArrayList中的元素。我们能通过构造函数来设定它的initialCapacity
  • size变量,用于记录当前数组的实际长度

扩容机制

添加新元素时,发现所需的最小minCapacity大于现有elementData的长度,需要调用Arrays.copyOf方法,给elementData赋值一个新的扩容数组。扩容后的newCapacity = oldCapacity + (oldCapacity » 1),也就是增长了原来的50%。

LinkedList

特点

LinkedList是一个双向链表。

底层实现:两个Node引用 + size

  • first: 指向链表头元素
  • last: 指向链表尾元素
  • size: 链表长度
  • Node:每个元素内部维护前后指针prev和next,还有值item

LinkedList和ArrayList删除性能对比

理论而言,ArrayList查找访问快,增删效率低(O(n));LinkedList查找访问慢,增删效率高(O(1))。实际而言,需要考虑这些场景:

  • 如果操作的元素在List尾部,比如删掉尾元素,那么二者的性能是完全一致的,都是O(1)
  • 对于ArrayList删除来说,耗时的操作在于List中间插入元素后,后续元素需要通过System.arrayCopy()进行复制;而对于LinkedList,需要先循环遍历到目标Node,找到之后通过修改其前后节点的引用将这个Node从链表上移除。因此待删除元素在数组中的位置十分重要,这关系到实际删除的时间是多半花在查找元素还是删除上
  • 由于System.arrayCopy()是原生方法优化不错,因此如果需要复制的元素个数不多,这个复制是非常快的,实战中可能比LinkedList的直接循环还快,这使得ArrayList在某些时候删除元素比LinkedList快

Vector

Vector和ArrayList的对比

相同点:

  • Vector和ArrayList都是动态数组,都支持动态扩容等特性
  • 二者都继承了List类,底层都是通过数组实现的
  • 添加、删除等方法中除了synchronized关键字的使用,具体实现是一致的

主要区别:

  • Vector是线程安全的(添加、删除等的方法的声明中,有synchronized关键字),支持同步访问;而ArrayList不是
  • ArrayList在底层数组不够用时,在原来的基础上拓展0.5倍,而Vector拓展1倍

鉴于Vector的加锁特点,单线程的环境中尽量使用ArrayList而不是Vector,否则性能很差。 ###

ArrayDeque

特点

提及ArrayDeque前,需要先介绍Java中的Deque接口。Deque的含义是“double ended queue”,即双端队列,它既可以当作栈使用,也可以当作队列使用。Deque的接口函数很多,但基本都是对对容器的两端进行操作,或添加,或删除,或查看。并且需要注意Deque中有两套接口,一套在操作失败时直接抛出异常,另一套则会返回空值。

array-deque-circular.png

ArrayDeque是Deque接口的一种实现,底层依赖于数组,为了满足可以同时在数组两端插入或删除元素的需求,该数组还必须是循环的,即循环数组(circular array),也就是说数组的任何一点都可能被看作起点或者终点。常用的操作中,需要注意以下几点:

  • 插入、删除元素时的下标越界问题
  • 插入元素时的扩容问题,其逻辑是申请一个更大的数组(原数组的两倍),然后将原数组复制过去。过程如下图所示

array-deque-capacity.png

需要注意,ArrayDeque是非线程安全的,当多个线程同时使用的时候,需要程序员手动同步。另外,该容器不允许放入null元素。

实现栈和队列

Deque 具备普通队列 FIFO 的功能,同时它也具备了 Stack 的 LIFO 功能,并且保留了 push 和 pop 函数,所以基于Deque的ArrayDeque实现可谓是生逢其时,恰到好处。

按照目前Java官方的说法,ArrayDeque经过了充足优化,同时适合实现栈和队列:

  • Java中有一个栈的类(Stack),但官方不推荐使用,这是因为Stack继承了Vector类。Vector类是较早Java代码中的遗留产物,因为同步锁等机制性能糟糕。而Stack 和 Vector 本来毫无关系,然而为了复用 Vector 中的方法来实现进栈、出栈等操作,被迫继承了 Vector,这使得 Stack 在基于数组实现上效率受影响。此外由于Stack继承了Vector,导致Stack可以复用Vector中的很多操作,这也使得Stack类设计不严谨

  • Java中没有队列类Queue,但是有Queue接口,实现时首选ArrayDeque,次选LinkedList。这是由于链表每次插入和删除都涉及到一个节点对象的创建和弃用,非常低效和浪费空间,而动态数组几乎是0花费的(数组扩容拷贝除外);此外链表不连续,在利用CPU缓存时不如数组方便

Java中的栈、队列创建方法:

Deque<Integer> stack = new ArrayDeque<Integer>();
Deque<Integer> queue = new ArrayDeque<Integer>();

JVM

内存模型

jvm-memory.jpg

JVM的内存结构大概分为:

  • 堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配,存放对象实例和数组,也是回收器主要管理的对象
  • 方法区(Method Area):线程共享。存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
  • 方法栈(JVM Stack):线程私有。存储局部变量表、操作栈、动态链接、方法出口、对象指针、基本数据类型、对象引用
  • 本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的Native方法服务。如Java使用C或者C++编写的接口服务时,代码在此区运行
  • 程序计数器(Program Counter Register):线程私有。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令

类加载机制

定义

类加载:虚拟机把编译后的描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机直接使用的Java类型的过程

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期完成的,从而通过牺牲一些性能开销来换取Java程序的高度灵活性,因为这个时候可以进行一些动态加载、扩展等等。

流程

类从被加载到虚拟机内存中开始、到卸载出内存为止,整个生命周期包括七个阶段:

  1. 加载
    • 通过类的全限定名来获取定义此类的二进制字节流
    • 将该二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构,数据存储数据结构由虚拟机自行定义
    • 在内存中生成一个代表这个类的java.lang.Class对象,它将作为程序访问方法区中的这些类型数据的外部接口
  2. 验证(链接第一阶段)
    • 确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,直接决定JVM能否承受恶意代码的攻击
    • 文件格式:字节流是否符合Class文件格式的规范、以及是否能被当前版本的虚拟机处理
    • 元数据:对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息
    • 字节码: 对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件
    • 符号引用:对类自身以外(如常量池中的各种符号引用)的信息进行匹配性校验
  3. 准备(链接第二阶段)
    • 为类变量(静态变量)分配内存
    • 设置类变量初始值:通常情况下零值
  4. 解析(链接第三阶段)
    • 虚拟机将常量池内的符号引用替换为直接引用
    • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量
    • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄
  5. 初始化
    • 真正执行类中定义的Java代码,根据 Java 程序的设定去初始化类变量和其他资源
  6. 使用
    • JVM使用代码
  7. 卸载(Java虚拟机结束生命周期)
    • 执行了System.exit()方法
    • 程序正常执行结束
    • 程序在执行过程中遇到了异常或错误而异常终止
    • 操作系统出现错误而导致Java虚拟机进程终止

双亲委派机制

JVM提供了三种类加载器将class文件给加载到JVM中去执行:

  • Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
  • Extension classLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。
  • Application classLoader:主要负责加载应用程序的主函数类

除此之外,还有自定义的类加载器User classLoader,它们之间的层次关系被称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现。

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回,自己不重复加载;只有父类加载器无法完成此加载任务时,才自己去加载。

使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。无论哪一个类加载器要加载某个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类,这样可以避免产生多个Object类导致混乱。

此外双亲委派模型更加安全,假如有人想替换某个系统级别的类,篡改它的实现。然而双亲委派模型下,这些系统类已经被Bootstrap classLoader加载过了,其他类加载器没有机会再去加载,从一定程度上防止了危险代码的植入。

Synchronized

synchronized是Java中的关键字,是一种同步锁,常用于多线程编程。它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象
  3. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象
  4. 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象

底层实现

synchronized是通过monitorenter、monitorexit、ACC_SYNCHRONIZED实现同步的。

  • monitorenter:每个对象都与一个monitor 相关联。当且仅当拥有所有者时(被拥有),monitor才会被锁定。执行到monitorenter指令的线程,会尝试去获得对应的monitor
    • 每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1
    • 当同一个线程再次获得该对象的锁的时候,计数器再次自增
    • 当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功
  • monitorexit:monitor的拥有者线程才能执行 monitorexit指令
    • 线程执行monitorexit指令,就会让monitor的计数器减一。如果计数器为0,表明该线程不再拥有monitor。其他线程就允许尝试去获得该monitor了
  • ACC_SYNCHRONIZED:同步方法的常量池中会有一个ACC_SYNCHRONIZED标志
    • 当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁
    • 在此期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住
    • 方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放

monitor是由ObjectMonitor类来实现的,类中有两个队列 _EntryList和 _WaitSet,它们是用来保存ObjectMonitor对象列表, 还有一个_owner指针指向持有ObjectMonitor对象的线程。

  1. 当多个线程访问同步代码时,线程会进入_EntryList区
  2. 当线程获取对象的monitor后进入 _Owner区并且将 _owner指向获得锁的线程(monitor对象被线程持有), _count++,其他线程则继续在 _EntryList区等待
  3. 若线程调用wait方法,则该线程进入 _WaitSet区等待被唤醒。线程执行完后释放monitor锁并且对ObjectMonitor中的值进行复位

特性

  • 有序性
    • as-if-serial:不管怎么重排序,单线程程序的执行结果都不能被改变
  • 可见性
    • happens-before:那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
    • 内存强制刷新
  • 原子性
    • 单一线程持有
  • 可重入性
    • 每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁

对象

在JVM中,对象在内存中的布局分为3块:对象头、实例数据和对齐填充。

  • 对象头:2个字宽(如果对象是数组则分配3个字宽,多的一个字宽用于存储数组的长度)
    • Mark Word:储存对象自身的运行时数据,例如对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等
    • 类型指针:标识JVM通过这个指针来确定这个对象是哪个类的实例
  • 实例数据:对象真正的有效信息(程序代码中定义的各种类型的字段内容)
  • 对齐填充:占位符,JVM要求对象的起始地址必须是8个字节的整数倍,而对象头已经是8的整数倍了,如果实例数据没有对齐就需要对齐填充来补全

synchronized使用的锁都放在对象头里,在mark word中会有2个bit作为锁的标志位,用于标示当前对象持有的是什么状态的锁。

锁升级(膨胀)

锁的状态是通过对象监视器在对象头中的字段来表明的。状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。四种状态分别是:

  • 无锁状态
  • 偏向锁状态:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低了获取锁的代价
  • 轻量级锁状态:锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能
  • 重量级锁状态:锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。

如果有另外的线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word ,修改指针来试图获取锁,并且复制了一份Mark Word叫做Lock Record。如果成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。

和Lock的区别

  • synchronized是关键字,JVM底层已经有对应实现;Lock是一个接口,JDK层面有丰富的API
  • synchronized会自动释放锁;Lock需要手动释放
  • synchronized不可中断(如果A不释放,B将一直等下去,不能被中断);Lock可以中断也可以不中断(如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情)
  • synchronized支持修饰的范围比较广,有代码块、方法、类等等;Lock只能锁住代码块
  • synchronized是非公平锁,而Lock可以公平也可以非公平
  • synchronized不能知道线程是否拿到了锁,而Lock可以通过tryLock的机制实现这个目的

自动装箱/拆箱

假如有这样两段代码:

Integer i3 = 100;
Integer i4 = 100;
System.out.println(i3 == i4); // true

Integer i5 = 1000;
Integer i6 = 1000;
System.out.println(i5 == i6); // false

为何会有上述差异?反编译.class文件,发现编译器实际上把上述代码进行了修改,添加了valueOf方法:

Integer i1 = Integer.valueOf(100);
Integer i2 = Integer.valueOf(100);
System.out.println(i1 == i2); // true

Integer i3 = Integer.valueOf(1000);
Integer i4 = Integer.valueOf(1000);
System.out.println(i3 == i4); // false

valueOf方法的定义中,作者为了避免重复创建对象,对Integer值做了缓存,如果这个值在缓存范围内,直接返回IntegerCache类中缓存好的对象,否则new一个新的对象返回。

那么这个缓存范围是多少?IntegerCache这一内部静态类给出了解答。该类只能在Integer这个类的内部访问,这个类在初始化的时候,会去加载JVM的配置,如果有值,就用配置的值初始化缓存数组,否则就缓存-128到127之间的值。

回到开篇的代码差异。i1i2赋值时,由于值在-128到-127之间,因此i1i2指向的是同一个缓存对象;而i3i4则new了两个不同的对象。

结论:比较两个Integer对象的值时,无论如何声明,一定要使用equals()比较,不能用==,并且注意Java中没有重载操作符这一说。

前文已经提到,JVM把声明的Integer类型变量加上了valueOf()方法。实际上,Java中广泛存在着这种行为。

Java中一共有四类八种基本数据类型,除掉这几种类型,其它的都是对象,也就是引用类型。而JDK1.5后,所有的基本数据类型都被加上了包装类型:

基本类型 -> 包装类型

第一类:整型 byte -> Byte, short -> Short, int -> Integer, long -> Long

第二类:浮点型 float -> Float, double -> Double

第三类:逻辑型 boolean -> Boolean

第四类:字符型 char -> Character

基本类型和包装类型之间可以互相转化:

  • 将int的变量转换成Integer对象,这个过程叫做装箱(boxing)
  • 将Integer对象转换成int类型值,这个过程叫做拆箱(unboxing)

假如我们有如下代码:

Integer integer1 = 100;
int int1 = integer1;

Long long1 = 100L;
long l1 = long1;

Boolean boolean1 = true;
boolean bool1 = boolean1;

反编译后,会发现代码变成:

Integer integer1 = Integer.valueOf(100); // 对int类型的值100装箱
int int1 = integer1.intValue(); // 对象类型integer1拆箱

Long long1 = Long.valueOf(100L); // 对long类型的值100L装箱
long l1 = long1.longValue(); // 对象类型l1拆箱

Boolean boolean1 = Boolean.valueOf(true); // 对int类型的值true装箱
boolean bool1 = boolean1.booleanValue(); // 对象类型boolean1拆箱

为什么要使用包装类型?

  • 对象是对现实世界的模拟(一切事物皆对象,通过面向对象的方式,将现实世界的事物抽象成对象)
  • 为泛型提供支持,例如List<Integer> list = new ArrayList<Integer>()这样的写法,因为泛型必须是一个对象
  • 提供了丰富的属性和API,例如Integer.MIN_VALUEInteger.max(a, b)等等

垃圾回收机制

实质:垃圾指的是无任何对象引用的对象,垃圾回收指的是清理“垃圾”占用的内存空间。

堆内存分配区域

  • 新生代(Young Generation) 几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0,Survivor1)区。大部分对象在Eden区中生成。当新对象生成,Eden Space申请失败(因为空间不足等),则会发起一次GC(Scavenge GC)。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了时,则将Eden区和Survivor0区存活对象复制到另一个Survivor1区,然后清空Eden和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空,如此往复。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,如果经历N次垃圾回收仍然存活,就会被移到年老代
  • 老生代(Old Generation) 年轻代中经历了N次垃圾回收后仍然存活的对象。此外,大对象一般会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组

Java对象引用

  • 强引用(Strong Reference):如Object obj = new Object(),这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。
  • 弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。

如何判断某个对象是垃圾

  • 引用计数 堆中每个对象都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。 缺点:难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销
  • 根搜索算法: 通过一系列名为“GC Roots”的对象作为起始点,寻找对应的引用节点;找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点,不断重复这个过程。当标记阶段完成了之后,所有的存活对象都已经被找出来了。其它的那些也是GC根对象不可达的对象,也就是说应用不会再用到它们了。这些就是垃圾对象,回收器将会在接下来的阶段中清除它们。 在遍历过程中,垃圾回收器会对内存中的整个对象图进行遍历,它先从GC根对象开始,然后是根对象引用的其它对象,比如实例变量。回收器将访问到的所有对象都标记为存活。

回收垃圾算法

  • 标记-清除算法 标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。一般适用于老生代
    • 优点:不需要进行对象的移动,存活对象多的时候很高效
    • 缺点:标记清除后会产生大量不连续的内存碎片
  • 标记—整理算法 对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。一般适用于老生代
    • 优点:解决碎片问题,并且只需要使用指针碰撞(把指针分界线向未分配内存空间移动一个对象的大小)就能给新对象分配内存
    • 缺点:增加了GC的耗时
  • 复制算法 将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。一般适用于新生代
    • 优点:标记阶段和复制阶段可以同时进行;内存回收时不用考虑内存碎片的出现
    • 缺点:需要一块能容纳下所有存活对象的额外的内存空间,一次性最大分配内存减半

垃圾回收过程细节

  1. 什么时候?

    • 新生代GC(Minor GC/Scavenge GC):发生在新生代的垃圾收集动作。因为Java对象大多都具有朝生夕灭的特性,因此Minor GC非常频繁(不一定等Eden区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。

    • 老年代GC(Major GC/Full GC):发生在老年代的垃圾回收动作。Major GC,经常会伴随至少一次Minor GC。由于老年代中的对象生命周期比较长,因此Major GC并不频繁,一般都是升到老年代的对象大于老年代剩余空间时进行,而且其速度一般会比Minor GC慢10倍以上。另外,如果分配了Direct Memory,在老年代中进行Full GC时,会顺便清理掉Direct Memory中的废弃对象。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。

    • 新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。当老年代也满了后,就会报OutOfMemory的异常。注意,如果gc与非gc时间耗时过大,超过了GCTimeRatio的限制,也会引发OutOfMemory异常。

  2. 对什么东西?

    从root搜索不到,而且经过第一次标记、清理后,仍然没有复活的对象。

  3. 做了什么事情?

    新生代做的是复制清理、from survivor、to survivor是干啥用的、老年代做的是标记清理、标记清理后碎片要不要整理

Java垃圾回收器种类

  • 串行垃圾回收器 持有应用程序所有的线程进行工作。它为单线程环境设计,只使用一个单独的线程进行垃圾回收,通过冻结所有应用程序线程进行工作
  • 并行垃圾回收器 JVM的默认垃圾回收器,使用多线程进行垃圾回收。当执行垃圾回收的时候它也会冻结所有的应用程序线程。
  • 并发标记扫描垃圾回收器 使用多线程扫描堆内存,标记需要清理的实例并且清理被标记过的实例。并发标记垃圾回收器只会在下面两种情况持有应用程序所有线程:
    • 当标记的引用对象在Tenured区域
    • 在进行垃圾回收的时候,堆内存的数据被并发的改变

STW

Stop The World,整个Java虚拟机应用线程暂停工作。在标记或者计算的过程中如果还有新对象产生,这个时候就需要STW。暂停工作吧,先把垃圾处理完。

OOM错误种类

Java heap space

应用程序尝试添加更多的数据放入堆空间区域,但没有足够的空间供它使用。引起错误的可能原因:

  • 用量/数据量激增
  • 内存泄漏

解决方案:

  • 分配更多的堆
  • 排查是否有内存泄漏

GC overhead limit exceeded

应用程序已经耗尽了几乎所有的可用内存,并且GC一直未能回收它。引起错误的可能原因:

  • 应用程序花费太多的时间做垃圾收集,清理的时间太少,可能是因为堆太小

解决方案:

  • 分配更多的堆

PermGen space

永久代的内存区域被耗尽。引起错误的可能原因:

  • 永久区中装入了太多的类或太大的类

解决方案:

  • 增大内存
  • 检查是否存在类加载器泄漏
  • 检查是否设置允许GC从PermGen space卸载类

Unable to create new native thread

Java应用程序已达到其可以启动线程数的限制。引起错误的可能原因:

  • Java进程大小已耗尽其内存地址空间
  • 操作系统的虚拟内存已完全耗尽

解决方案:

  • 增加操作系统级别的数量限制来绕过无法创建新的本机线程问题
  • 减少线程创建

JVM性能调优

原则

多数的 Java 应用不需要在服务器上进行 GC 优化; 多数导致 GC 问题的 Java 应用,都不是因为我们参数设置错误,而是代码问题; 在应用上线之前,先考虑将机器的 JVM 参数设置到最优(最适合); 减少创建对象的数量; 减少使用全局变量和大对象; GC 优化是到最后不得已才采用的手段; 在实际使用中,分析 GC 情况优化代码比优化 GC 参数要多得多。

总体而言,GC的目的是将转移到老年代的对象数量降低到最小; 减少 GC 的执行时间。

触发时机

  • Heap内存(老年代)持续上涨达到设置的最大内存值
  • Full GC 次数频繁
  • GC 停顿时间过长(超过1秒)
  • 应用出现OutOfMemory 等内存异常
  • 应用中有使用本地缓存且占用大量内存空间
  • 系统吞吐量与响应性能不高或下降

一般步骤

  1. 分析GC日志及dump文件,判断是否需要优化,确定瓶颈问题点
  2. 确定JVM调优量化目标
  3. 确定JVM调优参数(根据历史JVM参数来调整)
  4. 依次调优内存、延迟、吞吐量等指标
  5. 对比观察调优前后的差异
  6. 不断的分析和调整,直到找到合适的JVM参数配置
  7. 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪

GC调优

  1. 将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
  2. 大对象进入老年代,虽然大部分情况下,将对象分配在新生代是合理的。但是对于大对象这种做法却值得商榷,大对象如果首次在新生代分配可能会出现空间不足导致很多年龄不够的小对象被分配的老年代,破坏新生代的对象结构,可能会出现频繁的 full gc。因此,对于大对象,可以设置直接进入老年代(当然短命的大对象对于垃圾回收来说简直就是噩梦)。-XX:PretenureSizeThreshold 可以设置直接进入老年代的对象大小。
  3. 合理设置进入老年代对象的年龄,-XX:MaxTenuringThreshold 设置对象进入老年代的年龄大小,减少老年代的内存占用,降低 full gc 发生的频率。
  4. 设置稳定的堆大小,堆大小设置有两个参数:-Xms 初始化堆大小,-Xmx 最大堆大小。
  5. 注意: 如果满足下面的指标,则一般不需要进行 GC 优化: MinorGC 执行时间不到50ms; Minor GC 执行不频繁,约10秒一次; Full GC 执行时间不到1s; Full GC 执行频率不算频繁,不低于10分钟1次。

Full GC内存泄漏排查

  • jmap 手动dump出文件进行分析
  • 用jvisualvm等工具监控配置,自动dump
  • 使用Memory Analyzer(MAT)等工具实时查看分析内存使用情况

String类

String类的对象在声明时,主要生成了两个重要的东西:

  • private final char value[],这同时说明Java的String是基于char[]的
  • private int hash,即String的hashCode

构造函数则是这样写的:

public String(String original) {
    this.value = original.value;
    this.hash = original.hash;
}

结合一个例子理解上述的构造过程:

String str1 = new String("abc");
String str2 = new String("abc");

流程:

  1. JVM会去先检查看一看常量池里有没有”abc”这个对象,如果没有,把”abc”初始化为对象放入常量池,如果有,直接返回常量池内容
  2. 处理new关键词,在堆内存中开辟空间,由于hash这个字段是int类型的,成员变量初始化默认值为0
  3. 处理构造函数,hash是值类型,直接赋值,数组为引用类型,直接指向常量池中的地址。
  4. str2类似,直接使用常量池中的结果

stringConstruct.png

再看另一段代码:

String s1 = "100";
String s2 = "100";
System.out.println(s1 == s2); // true

String s3 = new String("100");
String s4 = new String("100");
System.out.println(s3 == s4); // false

再次出现了差异,并且反编译后发现与代码并没有被修改,与编译器无关。那么问题出在哪?答案是JVM的堆内存使用机制。

在JVM中,堆内存的一部分是常量池,主要的目的是为了避免频繁的创建和销毁对象而影响系统性能,实现了对象的共享。当代码执行到String s1 = "100"时,会先看常量池里有没有字符串刚好是“100”这个对象,如果没有,在常量池里创建初始化该对象,并把引用指向它;当执行到String s2 = "100"时,发现常量池已经有了”100”这个值,于是不会在常量池中创建一个新对象,而是把引用直接指向了原来的”100”对象。而s3s4的创建就不同了,由于它们是使用new关键字构造的,因此构造时就是明确告诉JVM,要在堆内存开辟一块新内存。

一张图总结上述四个字符串生成时内存的使用情况:

memoryPool.png

结论:比较两个String对象的值时,无论如何声明,一定要使用equals()比较,不能用==,并且注意Java中没有重载操作符这一说。


在引用类型中,”==”是比较两个引用是否指向堆内存里的同一个地址(同一个对象),而equals是一个普通的方法,该方法返回的结果依赖于自身的实现。于是,我们不妨深究一下Java常见类中equals()方法的实现。

  • 如果一个类没有继承其他类,那么它默认继承自Object这个类。而Object类的equals()实现方法很简单,即:判断两个引用是否指向堆内存中的同一块地址(同一个对象)
  • 而Integer类中,首先会判断传入比较的对象是不是Integer类型(obj instanceof Integer),如果是,才会进行后续的值比较;否则直接返回false。因此,诸如Integer与Long之类的比较,即使值相等,也会直接返回false
  • String类中的equals()实现略微复杂:
    • 先判断两个对象是否指向同一引用,如果是直接返回true
    • 判断两个对象是否都是String类型
    • 判断字符串底层char数组长度是否一致
    • 循环检查数组每一个char字符是否相等

面向对象

面向对象三大特征:继承、多态、封装

  • 继承:重用父类的代码,一个大类下的诸多小类,如果他们有共同的性质,只需要写一遍即可,不用重复多次(动物 -> 哺乳动物 -> 人)
  • 多态:多个小类同时继承一个大类,调用同一个方法时,运行时会智能匹配到子类的实现,得到不同的结果。需要满足三个条件:
    • 要用继承关系
    • 要有重写
    • 父类引用指向子类对象,也就是向上转型(Animal a = new Cat()),这样既可以使用子类改进拓展的功能,又可以抽取父类的共性
  • 封装:隐藏对象属性和实现细节,仅对外公开指定方法来控制程序中属性的访问和修改。几种修饰符:
    • private:访问权限仅限于类的内部,不能被任何外部类访问
    • default:任何处于当前包的类、接口等等都可以互相访问
    • protected:访问权限仅限于子类,其他类不可以
    • public:任何外部包、类都可以访问

所有的对象都是通过类来描绘的,但是反过来,并不是所有的类都是用来描绘对象的,如果一个类中没有包含足够的信息来描绘一个具体的对象,这样的类就是抽象类。由于抽象类不能实例化对象,所以抽象类必须被继承,才能被使用。也是因为这个原因,通常在设计阶段决定要不要设计抽象类。抽象类除了不能实例化对象之外,类的其它功能依然存在,成员变量、成员方法和构造方法的访问方式和普通类一样。

Java中使用abstract class定义抽象类。

如果某个类暂时不确定有属性或有具体的形为,建议先声明成接口,因为一个类可以实现多个接口,但只能继承一个类,声明为接口更灵活一些,也使实现类具有更好的扩展性。


重写和重载的区别:

  • 重写:指子类和父类的关系,子类重写了父类的方法,但方法名、参数类型、参数个数必须相同(加上@Override1
  • 重载:同一个类中,方法的名字相同,但参数个数、参数的类型不同。

这里引申到了一个进阶的问题,即如何比较两个类对象。结论是:

  1. 不要自己写方法,或者用==去判断,而是应该重写父类的 equals方法
  2. 当我们在实际业务中需要重写(覆盖)equals()方法时,根据规范,我们一定要重写(覆盖)hashCode()方法。

Java反射机制

  1. 什么是Java反射机制?

    一般情况下,我们使用某个类时必定知道它是什么类,是用来做什么的。于是我们直接对这个类进行实例化,之后使用这个类对象进行操作。这样子进行类对象的初始化,我们可以理解为“正”。

    与之相反,反射 (Reflection) 是 Java 的特征之一,其核心是 JVM 在运行时才动态加载类或调用方法/访问属性,它不需要事先(写代码的时候或编译期)知道运行对象是谁。

    Java 反射主要提供以下功能:

    • 在运行时判断任意一个对象所属的类
    • 在运行时构造任意一个类的对象
    • 在运行时判断任意一个类所具有的成员变量和方法(通过反射甚至可以调用private方法)
    • 在运行时调用任意一个对象的方法

    具体可以参见这张运行图:

    reflection.png

  2. Java反射机制有什么用途?

    反射最重要的用途就是开发各种通用框架。很多框架(比如 Spring)都是配置化的(比如通过 XML 文件配置 Bean),为了保证框架的通用性,它们可能需要根据配置文件加载不同的对象或类,调用不同的方法,这个时候就必须用到反射,运行时动态加载需要加载的对象。

多线程锁

ReentrantLock

Java语言直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁。

ReentrantLock相比synchronized有几个特点:

  • 必须在代码中去获取锁,用完之后必须释放
  • 比起synchronized更安全,因为可以搭配try…finally代码块,允许用trylock尝试获取锁,即便失败了也不会让程序退出运行或者导致死锁,允许额外的容错处理机会

ReentrantLock 内部有一个抽象类 Sync,继承了 AbstractQueuedSynchronizer。而公平锁的实现就是 FairSync,非公平锁的实现就是 NonFairSync。具体生成锁时,只需要在构造方法中使用一个 boolean 参数即可。默认是非公平锁。

底层实现

AbstractQueuedSynchronizer

AbstractQueuedSynchronizer (抽象队列同步器,以下简称 AQS)出现在 JDK 1.5 中,是很多同步器的基础框架,比如 ReentrantLock、CountDownLatch 和 Semaphore 等都是基于 AQS 实现的。我们也可以基于 AQS定制出所需要的同步器。

在 AQS 内部,通过维护一个FIFO 队列来管理多线程的排队工作。在公平竞争的情况下,无法获取同步状态的线程将会被封装成一个节点,置于队列尾部。

AQS有两种模式:共享和独享。

独享模式下获取同步状态的流程:

  1. 调用 tryAcquire 方法尝试获取同步状态
  2. 获取成功,直接返回;获取失败,将线程封装到节点中,并将节点入队
  3. 入队节点在 acquireQueued 方法中自旋获取同步状态
  4. 若节点的前驱节点是头节点,则再次调用 tryAcquire 尝试获取同步状态
  5. 获取成功,当前节点将自己设为头节点并返回
  6. 获取失败,可能再次尝试,也可能会被阻塞。这里简单认为会被阻塞。

独享模式下释放同步状态的流程:

  1. 调用 tryRelease(arg) 尝试释放同步状态
  2. 根据条件判断是否应该唤醒后继线程

共享模式下获取同步状态的流程:

  1. 获取共享同步状态
  2. 若获取失败,将线程封装到节点中,并将节点入队
  3. 如果前驱为头结点,再次尝试获取共享同步状态
  4. 获取成功则将自己设为头结点,如果后继节点是共享类型的,则唤醒
  5. 若失败,将节点状态设为 SIGNAL,再次尝试。若再次失败,线程进入等待状态

共享模式下释放同步状态的流程:

  1. 调用doReleaseShared
  2. 如果 head 节点等待状态为 SIGNAL,则将 head 节点状态设为 0,并唤醒后继节点;如果 head 节点等待状态为 0,则将 head 节点状态设为 PROPAGATE,保证唤醒能够正常传播下去

总结:独占模式下,只有一个节点线程可以成功获取同步状态,也只有获取已同步状态节点线程才可以释放同步状态。但在共享模式下,多个共享节点线程可以同时获得同步状态,在一些线程获取同步状态的同时,可能还会有另外一些线程正在释放同步状态。

公平锁

static final class FairSync extends Sync {
    // 以公平的方式锁
    final void lock() {
        // 调用AQS框架的逻辑
        acquire(1);
    }
    // AQS acquire 方法会调用tryAcquire这个方法
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&  compareAndSetState(0, acquires)) {
                // 成功获得锁,设置锁的所有者为当前线程
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            // 可重入锁的逻辑
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

公平锁调用的是 AQS 的acquire方法,而AQS 会回调子类的tryAcquire方法。具体逻辑是:

  1. 获取 state 变量,如果是 0,说明当前锁没有线程占用, 可以获取
  2. 如果当前线程不是锁的占有者,判断 AQS 队列中是否有等待的线程,如果没有,就使用 CAS 尝试获取。如果CAS能修改state = 1,说明锁获取成功,将锁的持有线程修改为当前线程。所谓公平,就体现这一步的“判断 AQS 队列中是否有等待的线程中,对应的函数是hasQueuedPredecessors,避免了新来的线程插队的情况
  3. 如果当前线程已经是锁的占有者,允许重入获取锁,同样也是修改state值,不过需要检查是否超出了最大持有锁上限
  4. 任何情况下获取锁失败,返回false,AQS会将这个线程放进队列并挂起。

非公平锁

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    final void lock() {
        // 非公平获得锁,进来直接使用CAS修改,修改成功就是获得锁
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            // acquire 是AQS里面的方法,最终会调用到非公平锁的实现方法tryAcquire
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        // 非公平获得锁
        return nonfairTryAcquire(acquires);
    }
}
abstract static class Sync extends AbstractQueuedSynchronizer {
...
    final boolean nonfairTryAcquire(int acquires) {
      // 当前线程
      final Thread current = Thread.currentThread();
      // state 状态
      int c = getState();
      if (c == 0) {
          // CAS 修改state 值
          if (compareAndSetState(0, acquires)) {
              // 成功获得锁,修改锁的所有者线程
              setExclusiveOwnerThread(current);
              return true;
          }
      } else if (current == getExclusiveOwnerThread()) {
          // 检查锁的所有者是否是当前线程       
          // 当前线程获得锁,可重入逻辑
          int nextc = c + acquires;
          if (nextc < 0) // overflow
              throw new Error("Maximum lock count exceeded");
          setState(nextc);
          return true;
      }
      return false;
  }
...
}

非公平锁与公平锁调用的acquire方法是一样的,不同的是tryAcquire部分。

非公平锁进来直接尝试CAS修改state值,如果CAS能修改state = 1,说明锁获取成功,将锁的持有线程修改为当前线程。而如果失败,就会调用AQS的acquire方法,而AQS 会回调子类的tryAcquire方法,里面进一步调用了nonfairTryAcquire这个方法。具体逻辑是:

  1. 获取当前线程,当前 state 值
  2. 如果 state 值为0 ,直接使用CAS 修改,修改成功代表获得锁,如果成功获得锁,修改锁的所有者线程为当前线程
  3. 如果 state 值不为0,检查锁的所有者是否是当前线程,如果是,进入可重入逻辑,修改state,成功获得锁
  4. 没有获得锁,返回false

因为非公平锁没有进行队列检查等步骤,相对更快速一些,所以ReentrantLock默认使用了非公平锁。

ReentrantReadWriteLock

ReentrantLock保证了只有一个线程可以执行临界区代码,但是有些时候,这种保护有点过头,因为其显著降低了并发效率。实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。

ReadWriteLock解决了上述问题,它有以下特点:

  • 提高读取效率
  • 只允许一个线程写入
  • 允许多个线程在没有写入时同时读取;
  • 适合读多写少的场景
public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

StampedLock

StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入。

和ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取,先获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后验证版本号,如果在读取过程中没有写入,版本号不变则验证成功;如果在读取过程中有写入,版本号会发生变化,验证将失败。此时,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

总的来说,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个StampedLock。此外,StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。

线程池

简介

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,主要作用是维护多个线程,等待监督管理者分配可并发执行的任务。

Java中可以直接使用new Thread生成一个新线程,然而这样生成的线程性能差,缺乏管理,功能少。使用线程池可以带来一系列好处:

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行

参数

Java使用ThreadPoolExecutor生成线程池,其构造函数有一些重要参数:

  • corePoolSize:核心池的大小
  • maximumPoolSize:线程池最大线程数
  • poolSize: 线程池中当前线程的数量
    • 如果poolSize < corePoolSize,新增加一个线程处理新的任务
    • 如果poolSize = corePoolSize,新任务会被放入阻塞队列等待
    • 如果阻塞队列的容量达到上限,且这时poolSize < maximumPoolSize,新增线程来处理任务
    • 如果阻塞队列满了,且poolSize = maximumPoolSize,那么线程池已经达到极限,会根据饱和策略RejectedExecutionHandler拒绝新的任务
  • keepAliveTime:线程没有任务执行时最多保持多久时间会终止
  • workQueue:一个阻塞队列,用来存储等待执行的任务。阻塞队列一般有以下几种选择:
    • ArrayBlockingQueue
      • 有界队列
      • 加锁保证安全
      • 队列满就阻塞,超时或者队列不满则唤醒被阻塞的出队线程
      • 入队阻塞调用建议使用offer方法,可以及时获取boolean返回值判断是否成功;使用put方法需要考虑阻塞情况,因为出队速度大于入队速度
    • LinkedBlockingQueue
      • 无长度上限,需要当心内存溢出
    • SynchronousQueue
      • 无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。
    • PriorityBlockingQueue
    • DelayedWorkQueue
  • threadFactory:执行程序创建新线程时使用的工厂
  • handler:表示当拒绝处理任务时的策略
    • AbortPolicy:丢弃任务并抛出RejectedExecutionException异常
    • DiscardPolicy:丢弃任务,但是不抛出异常
    • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务,不断重复此过程
    • CallerRunsPolicy:由调用线程处理该任务
  • runState:volatile关键词修饰的线程池运行状态
    • RUNNING:线程池接受新任务并执行队列任务中
    • SHUTDOWN:不再接受新任务,但是会继续执行等待队列Queued中的任务
    • STOP:不再接受新任务,同时也不执行等待队列Queued中的任务,并且会尝试终止正在执行中的任务
    • TERMINATED:线程池中所有线程已经停止运行

种类

Java通过Executors提供几种线程池,底层均是由ThreadPoolExecutor实现的,例如:

  • newFixedThreadPool
    • 定长线程池,可控制线程最大并发数,超出的线程会在队列中等待
    • 使用完毕必须手动关闭线程池, 否则会一直在内存中存在
  • newCachedThreadPool
    • 可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
    • 使用SynchronousQueue
  • newScheduledThreadPool
    • 定长线程池,支持定时及周期性任务执行
    • 使用DelayedWorkQueue
  • newSingleThreadExecutor
    • 单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行
  • newWorkStealingPool
    • 每个线程池分配一个双端队列(本地队列)用于存放需要执行的任务,当自己的队列没有数据的时候从其它线程池队列中获得一个任务继续执行
    • 涉及到并行编程肯定涉及到并发安全的问题,有可能在“偷取”任务过程中A提前抢占了这个任务,那么B的偷取就会失败。大多数实现会尽量避免发生这个问题
    • 一般是自己的本地队列采取LIFO(后进先出),偷取时采用FIFO(先进先出),一个从头开始执行,一个从尾部开始执行,由于偷取的动作十分快速,会大量降低这种冲突,也是一种优化方式