JAVA 的随手PICK
Optional
Java 8 引入了 Optional,目的主要是为了解决空值问题,这个Optional是借鉴的Google Guava
类库的Optional类,捏妈的,谷歌好顶
of
为非Null值创建一个Optional
of方法通过工厂方法创建Optional实例,需要注意的是传入的参数不能为null,否则抛出NullPointerException
。
1 | // 给与一个非空值 |
ofNullable
为指定的值创建一个Optional,如果指定的值为null,则返回一个空的Optional。可为空的Optional
1 | // 下面创建了一个不包含任何值的Optional实例 |
isPresent
如果值存在返回true,否则返回false
类似下面的代码:
1 | // isPresent方法用来检查Optional实例中是否包含值 |
get
如果Optional有值则将其返回,否则跑出NoSuchElementException
1 | // 执行下面的代码抛出NoSuchElementException |
ifPresent
如果Optional实例有值则为其调用consumer ,否则不做处理。
要理解ifPresent方法,首先需要了解Consumer类。简答地说,Consumer类包含一个抽象方法。该抽象方法对传入的值进行处理,但没有返回值。 Java8支持不用接口直接通过lambda表达式传入参数。
如果Optional实例有值,调用ifPresent()可以接受接口段或lambda表达式。类似下面的代码:
1 | // ifPresent方法接受lambda表达式作为参数。 |
orElse
如果有值则将其返回,否则返回指定的其它值。
如果Optional实例有值则将其返回,否则返回orElse方法传入的参数。示例如下:
1 | // 如果值不为null,orElse方法返回Optional实例的值,否则返回传入的消息 |
orElseGet
orElseGet与orElse方法类似,区别在于得到的默认值。orElse方法将传入的字符串作为默认值,orElseGet方法可以接受Supplier接口的实现用来生成默认值。示例如下:
1 | // orElseGet与orElse方法类似,区别在于orElse传入的是默认值, |
orElseThrow
如果有值则将其返回,否则抛出supplier接口创建的异常。
在orElseGet方法中,我们传入一个Supplier接口。然而,在orElseThrow中我们可以传入一个lambda表达式或方法,如果值不存在来抛出异常。示例如下:
1 | try { |
ValueAbsentException
定义如下:
1 | class ValueAbsentException extends Throwable { |
map
如果有值,则对其执行调用mapping函数得到返回值。如果返回值不为null,则创建包含mapping返回值的Optional作为map方法返回值,否则返回空Optional。
map方法用来对Optional实例的值执行一系列操作。通过一组实现了Function接口的lambda表达式传入操作。如果你不熟悉Function接口,可以参考这篇博客。map方法示例如下:
1 | // map方法执行传入的lambda表达式参数对Optional实例的值进行修改。 |
flatMap
如果有值,为其执行mapping函数返回Optional类型返回值,否则返回空Optional。flatMap与map(Funtion)方法类似,区别在于flatMap中的mapper返回值必须是Optional。调用结束时,flatMap不会对结果用Optional封装。
参照map函数,使用flatMap重写的示例如下:
1 | // flatMap与map(Function)非常类似,区别在于传入方法的lambda表达式的返回类型。 |
filter
filter个方法通过传入限定条件对Optional实例的值进行过滤。文档描述如下:
如果有值并且满足断言条件返回包含该值的Optional,否则返回空Optional。
读到这里,可能你已经知道如何为filter方法传入一段代码。是的,这里可以传入一个lambda表达式。对于filter函数我们应该传入实现了Predicate接口的lambda表达式。如果你不熟悉Predicate接口,可以参考 这篇文章。
现在我来看看filter的各种用法,下面的示例介绍了满足限定条件和不满足两种情况:
1 | // filter方法检查给定的Option值是否满足某些条件。 |
双冒号
定义
双冒号运算操作符是类方法的句柄,lambda表达式的一种简写,这种简写的学名叫eta-conversion或者叫η-conversion。
理解
英文格式双冒号::
,读:double colon,双冒号(::)运算符在Java 8中被用作方法引用(method reference),方法引用是与lambda表达式相关的一个重要特性。它提供了一种执行方法的方法,为此,方法引用需要由兼容的函数式接口组成的目标类型上下文。
使用lambda表达式会创建匿名函数, 但有时候需要使用一个lambda表达式只调用一个已经存在的方法(不做其它), 所以这才有了方法引用!
类型 | 引用语法 | 案例 |
---|---|---|
引用静态方法 | 类名::静态方法名 | Integer::parseInt |
引用特定对象实例方法 | 对象::实例方法名 | System.out::println |
引用特定类型的任意对象的实例方法 | 特定类型::实例方法名 | String::compareTolgnoreCase |
引用父类实例方法 | super::方法名 | |
引用类构造方法 | 类名::new | ArrayList::new |
引用数组构造方法 | 数组类型[]::new | String[]::new |
Case
1 | import org.junit.Test; |
1 | import org.junit.Test; |
1 | import org.junit.Test; |
注意:不要和引用静态方法搞混,认为为什么
compareToIgnoreCase
是非静态方法却可以使用类名去引用,这两者根本不是一回事,双冒号前的类含义也不同,就是两个应用方向
1 | import org.junit.Test; |
1 | //注意:该类无需实现接口 |
1 | import java.util.function.Function; |
1 | public class Colon{ |
Expansion
上面的写法里面我有个疑惑的地方就是
1 | ColonNoParam cnp = Colon::new; |
为什么这个地方能直接实例化接口,其实这个是一个匿名内部类+Lamda复合的实现方式
1 | ColonWithParam cwp = Colon::new; |
这个和
1 | // 整了一个匿名内部函数,同时这里只是定义了函数内容,并没有执行,在下面一行createColon的时候才执行了 |
这样的写法是等价的,属实让人有点摸不清楚,
这里还涉及一个Lamda的注释@FunctionalInterface
1 | @FunctionalInterface 是 Java 8 新加入的一种接口,用于指明该接口类型声明是根据 Java 语言规范定义的函数式接口。Java 8 还声明了一些 Lambda 表达式可以使用的函数式接口,当你注释的接口不是有效的函数式接口时,可以使用 @FunctionalInterface 解决编译层面的错误。 |
String
存储结构
String 内部实际存储结构为 char 数组,源码如下:
1 | public final class String |
构造方法
String 字符串有以下 4 个重要的构造方法:
1 | // String 为参数的构造方法 |
StringBuffer StringBuilder char[] 和 String
equals
1 | public boolean equals(Object anObject) { |
String 类型重写了 Object 中的 equals() 方法,equals() 方法需要传递一个 Object 类型的参数值,在比较时会先通过 instanceof 判断是否为 String 类型.
还有一个和 equals() 比较类似的方法 equalsIgnoreCase(),它是用于忽略字符串的大小写之后进行字符串对比。
compareTo()
compareTo() 方法用于比较两个字符串,返回的结果为 int 类型的值,源码如下:
1 | public int compareTo(String anotherString) { |
从源码中可以看出,compareTo() 方法会循环对比所有的字符,当两个字符串中有任意一个字符不相同时,则 return char1-char2。比如,两个字符串分别存储的是 1 和 2,返回的值是 -1;如果存储的是 1 和 1,则返回的值是 0 ,如果存储的是 2 和 1,则返回的值是 1。
还有一个和 compareTo() 比较类似的方法 compareToIgnoreCase(),用于忽略大小写后比较两个字符串。
可以看出 compareTo() 方法和 equals() 方法都是用于比较两个字符串的,但它们有两点不同:
- equals() 可以接收一个 Object 类型的参数,而 compareTo() 只能接收一个 String 类型的参数;
- equals() 返回值为 Boolean,而 compareTo() 的返回值则为 int。
它们都可以用于两个字符串的比较,当 equals() 方法返回 true 时,或者是 compareTo() 方法返回 0 时,则表示两个字符串完全相同。
其他方法
- indexOf():查询字符串首次出现的下标位置
- lastIndexOf():查询字符串最后出现的下标位置
- contains():查询字符串中是否包含另一个字符串
- toLowerCase():把字符串全部转换成小写
- toUpperCase():把字符串全部转换成大写
- length():查询字符串的长度
- trim():去掉字符串首尾空格
- replace():替换字符串中的某些字符
- split():把字符串分割并返回字符串数组
- join():把字符串数组转为字符串
个人不太熟悉的就是join()了
1 | String message = String.join("-", "Java", "is", "cool"); |
常见问题
为什么 String 类型要用 final 修饰?
从 String 类的源码我们可以看出 String 是被 final 修饰的不可继承类
Java 语言之父 James Gosling 的回答是,他会更倾向于使用 final,因为它能够缓存结果,当你在传参时不需要考虑谁会修改它的值;如果是可变类的话,则有可能需要重新拷贝出来一个新值进行传参,这样在性能上就会有一定的损失。– 性能
James Gosling 还说迫使 String 类设计成不可变的另一个原因是安全,当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验,如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题,这是迫使 String 类设计成不可变类的一个重要原因。 – 安全
另外只有字符串是不可变时,我们才能实现字符串常量池,字符串常量池可以为我们缓存字符串,提高程序的运行效率,如图:
试想一下如果 String 是可变的,那当 s1 的值修改之后,s2 的值也跟着改变了,这样就和我们预期的结果不相符了,因此也就没有办法实现字符串常量池的功能了。
因为final修饰了,1.所以String天生线程安全 2. 非常适合做HashMap的key 3.利用不可变性实现字符串常量池
== 和 equals 的区别是什么?
== 对于基本数据类型来说,是用于比较 “值”是否相等的;而对于引用类型来说,是用于比较引用地址是否相同的。
String 和 StringBuilder、StringBuffer 有什么区别?
因为 String 类型是不可变的,所以在字符串拼接的时候如果使用 String 的话性能会很低,因此我们就需要使用另一个数据类型 StringBuffer,它提供了 append 和 insert 方法可用于字符串的拼接,它使用 synchronized 来保证线程安全,如下源码所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 >
> public synchronized StringBuffer append(Object obj) {
> toStringCache = null;
> super.append(String.valueOf(obj));
> return this;
> }
>
>
> public synchronized StringBuffer append(String str) {
> toStringCache = null;
> super.append(str);
> return this;
> }
>
>
因为它使用了 synchronized 来保证线程安全,所以性能不是很高,于是在 JDK 1.5 就有了 StringBuilder,它同样提供了 append 和 insert 的拼接方法,但它没有使用 synchronized 来修饰,因此在性能上要优于 StringBuffer,所以在非并发操作的环境下可使用 StringBuilder 来进行字符串拼接。
String 的 intern() 方法有什么含义?
new String都是在堆上创建字符串对象。当调用 intern() 方法时,编译器会将字符串添加到常量池中(stringTable维护),并返回指向该常量的引用。
String 类型在 JVM(Java 虚拟机)中是如何存储的?编译器对 String 做了哪些优化?
String 常见的创建方式有两种,new String() 的方式和直接赋值的方式,直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值;而 new String() 的方式一定会先在堆上创建一个字符串对象,然后再去常量池中查询此字符串的值是否已经存在,如果不存在会先在常量池中创建此字符串,然后把引用的值指向此字符串,如下代码所示:
1
2
3
4
5
6 > String s1 = new String("Java");
> String s2 = s1.intern();
> String s3 = "Java";
> System.out.println(s1 == s2); // false
> System.out.println(s2 == s3); // true
>
它们在 JVM 存储的位置,如下图所示:
小贴士:JDK 1.7 之后把永生代换成的元空间,把字符串常量池从方法区移到了 Java 堆上。
除此之外编译器还会对 String 字符串做一些优化,例如以下代码:
1
2
3
4 > String s1 = "Ja" + "va";
> String s2 = "Java";
> System.out.println(s1 == s2);
>
虽然 s1 拼接了多个字符串,但对比的结果却是 true,我们使用反编译工具,看到的结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 > Compiled from "StringExample.java"
> public class com.lagou.interview.StringExample {
> public com.lagou.interview.StringExample();
> Code:
> 0: aload_0
> 1: invokespecial #1 // Method java/lang/Object."<init>":()V
> 4: return
> LineNumberTable:
> line 3: 0
> public static void main(java.lang.String[]);
> Code:
> 0: ldc #2 // String Java
> 2: astore_1
> 3: ldc #2 // String Java
> 5: astore_2
> 6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
> 9: aload_1
> 10: aload_2
> 11: if_acmpne 18
> 14: iconst_1
> 15: goto 19
> 18: iconst_0
> 19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
> 22: return
> LineNumberTable:
> line 5: 0
> line 6: 3
> line 7: 6
> line 8: 22
> }
>
HashMap
HashMap 底层是如何实现的?在 JDK 1.8 中它都做了哪些优化?
在 JDK 1.7 中 HashMap 是以数组加链表的形式组成的, JDK 1.8 之后新增了红黑树的组成结构,当链表大于 8 并且容量大于 64 时, 链表结构会转换成红黑树结构,它的组成结构如下图所示:
数组中的元素我们称之为哈希桶, JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。
More
- JDK 1.8 HashMap 扩容时做了哪些优化?
JDK 1.8 则新增了红黑树结构,当链表长度达到 8 并且容器达到 64 时会转换为红黑树存储,以提升元素的操作性能。
- 加载因子为什么是 0.75?
加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。
那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?
这其实是出于容量和性能之间平衡的结果:
当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。
- 当有哈希冲突时,HashMap 是如何查找并确认元素的?
当哈希冲突时我们需要通过判断 key 值是否相等,才能确认此元素是不是我们想要的元素。
- HashMap 源码中有哪些重要的方法?
下方源码
- HashMap 是如何导致死循环的?
以 JDK 1.7 为例,假设 HashMap 默认大小为 2,原本 HashMap 中有一个元素 key(5),我们再使用两个线程:t1 添加元素 key(3),t2 添加元素 key(7),当元素 key(3) 和 key(7) 都添加到 HashMap 中之后,线程 t1 在执行到 Entry<K,V> next = e.next; 时,交出了 CPU 的使用权,源码如下:
1 | void transfer(Entry[] newTable, boolean rehash) { |
那么此时线程 t1 中的 e 指向了 key(3),而 next 指向了 key(7) ;之后线程 t2 重新 rehash 之后链表的顺序被反转,链表的位置变成了 key(5) → key(7) → key(3),其中 “→” 用来表示下一个元素。
当 t1 重新获得执行权之后,先执行 newTalbe[i] = e 把 key(3) 的 next 设置为 key(7),而下次循环时查询到 key(7) 的 next 元素为 key(3),于是就形成了 key(3) 和 key(7) 的循环引用,因此就导致了死循环的发生,如下图所示:
当然发生死循环的原因是 JDK 1.7 链表插入方式为首部倒序插入,这个问题在 JDK 1.8 得到了改善,变成了尾部正序插入。
有人曾经把这个问题反馈给了 Sun 公司,但 Sun 公司认为这不是一个问题,因为 HashMap 本身就是非线程安全的,如果要在多线程下,建议使用 ConcurrentHashMap 替代,但这个问题在面试中被问到的几率依然很大,所以在这里需要特别说明一下。
源码
HashMap 源码中包含了以下几个属性:
1 | // HashMap 初始化长度 |
HashMap 源码中三个重要方法:查询、新增和数据扩容。
1 | public V get(Object key) { |
从以上源码可以看出,当哈希冲突时我们需要通过判断 key 值是否相等,才能确认此元素是不是我们想要的元素。
HashMap 第二个重要方法:新增方法,源码如下:
1 | public V put(K key, V value) { |
新增方法的执行流程,如下图所示:
HashMap 第三个重要的方法是扩容方法,源码如下:
1 | final Node<K,V>[] resize() { |
从以上源码可以看出,JDK 1.8 在扩容时并没有像 JDK 1.7 那样,重新计算每个元素的哈希值,而是通过高位运算(e.hash & oldCap)来确定元素是否需要移动,比如 key1 的信息如下:
- key1.hash = 10 0000 1010
- oldCap = 16 0001 0000
使用 e.hash & oldCap 得到的结果,高一位为 0,当结果为 0 时表示元素在扩容时位置不会发生任何变化,而 key 2 信息如下:
- key2.hash = 10 0001 0001
- oldCap = 16 0001 0000
这时候得到的结果,高一位为 1,当结果为 1 时,表示元素在扩容时位置发生了变化,新的下标位置等于原下标位置 + 原数组长度,如下图所示:
其中红色的虚线图代表了扩容时元素移动的位置。
Thread
线程(Thread)是并发编程的基础,也是程序执行的最小单元,它依托进程而存在。一个进程中可以包含多个线程,多线程可以共享一块内存空间和一组系统资源,因此线程之间的切换更加节省资源、更加轻量化,也因此被称为轻量级的进程。
线程的状态在 JDK 1.5 之后以枚举的方式被定义在 Thread 的源码中,它总共包含以下 6 个状态:
NEW,新建状态,线程被创建出来,但尚未启动时的线程状态;
RUNNABLE,就绪状态,表示可以运行的线程状态,它可能正在运行,或者是在排队等待操作系统给它分配 CPU 资源;
BLOCKED,阻塞等待锁的线程状态,表示处于阻塞状态的线程正在等待监视器锁,比如等待执行 synchronized 代码块或者使用 synchronized 标记的方法;
WAITING,等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作,比如,一个线程调用了 Object.wait() 方法,那它就在等待另一个线程调用 Object.notify() 或 Object.notifyAll() 方法;
TIMED_WAITING,计时等待状态,和等待状态(WAITING)类似,它只是多了超时时间,比如调用了有超时时间设置的方法 Object.wait(long timeout) 和 Thread.join(long timeout) 等这些方法时,它才会进入此状态;
TERMINATED,终止状态,表示线程已经执行完成。
源码
线程的工作模式是,首先先要创建线程并指定线程需要执行的业务方法,然后再调用线程的 start() 方法,此时线程就从 NEW(新建)状态变成了 RUNNABLE(就绪)状态,此时线程会判断要执行的方法中有没有 synchronized 同步代码块,如果有并且其他线程也在使用此锁,那么线程就会变为 BLOCKED(阻塞等待)状态,当其他线程使用完此锁之后,线程会继续执行剩余的方法。
当遇到 Object.wait() 或 Thread.join() 方法时,线程会变为 WAITING(等待状态)状态,如果是带了超时时间的等待方法,那么线程会进入 TIMED_WAITING(计时等待)状态,当有其他线程执行了 notify() 或 notifyAll() 方法之后,线程被唤醒继续执行剩余的业务方法,直到方法执行完成为止,此时整个线程的流程就执行完了,执行流程如下图所示:
转化图:
重点问题
为什么State里面没有Running状态
有人常觉得 Java 线程状态中还少了个 running 状态,这其实是把两个不同层面的状态混淆了。对 Java 线程状态而言,不存在所谓的running 状态,它的 runnable 状态包含了 running 状态。
我们可能会问,为何 JVM 中没有去区分这两种状态呢?现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin式)。
更复杂的可能还会加入优先级(priority)的机制。
这个时间分片通常是很小的,一个线程一次最多只能在 cpu 上运行比如10-20ms 的时间(此时处于 running 状态),也即大概只有0.01秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)
这一切换的过程称为线程的上下文切换(context switch),当然 cpu 不是简单地把线程踢开就完了,还需要把被相应的执行状态保存到内存中以便后续的恢复执行。显然,10-20ms 对人而言是很快的,
不计切换开销(每次在1ms 以内),相当于1秒内有50-100次切换。事实上时间片经常没用完,线程就因为各种原因被中断,实际发生的切换次数还会更多。
也这正是单核 *CPU 上实现所谓的“并发*(concurrent)”的基本原理,但其实是快速切换所带来的假象,这有点类似一个手脚非常快的杂耍演员可以让好多个球同时在空中运转那般。
时间分片也是可配置的,如果不追求在多个线程间很快的响应,也可以把这个时间配置得大一点,以减少切换带来的开销。如果是多核CPU,才有可能实现真正意义上的并发,这种情况通常也叫并行(pararell),不过你可能也会看到这两词会被混着用,这里就不去纠结它们的区别了。
通常,Java的线程状态是服务于监控的,如果线程切换得是如此之快,那么区分 ready 与 running 就没什么太大意义了。
当你看到监控上显示是 running 时,对应的线程可能早就被切换下去了,甚至又再次地切换了上来,也许你只能看到 ready 与 running 两个状态在快速地闪烁。当然,对于精确的性能评估而言,获得准确的 running 时间是有必要的。
现今主流的 JVM 实现都把 Java 线程一一映射到操作系统底层的线程上,把调度委托给了操作系统,我们在虚拟机层面看到的状态实质是对底层状态的映射及包装。JVM 本身没有做什么实质的调度,把底层的 ready 及 running 状态映射上来也没多大意义,因此,统一成为runnable 状态是不错的选择。
我们将看到,Java 线程状态的改变通常只与自身显式引入的机制有关。
我们知道传统的I/O都是阻塞式(blocked)的,原因是I/O操作比起cpu来实在是太慢了,可能差到好几个数量级都说不定。如果让 cpu 去等I/O 的操作,很可能时间片都用完了,I/O 操作还没完成呢,不管怎样,它会导致 cpu 的利用率极低。所以,解决办法就是:一旦线程中执行到 I/O 有关的代码,相应线程立马被切走,然后调度 ready 队列中另一个线程来运行。这时执行了 I/O 的线程就不再运行,即所谓的被阻塞了。它也不会被放到调度队列中去,因为很可能再次调度到它时,I/O 可能仍没有完成。线程会被放到所谓的等待队列中,处于上图中的 waiting 状态:
当然了,我们所谓阻塞只是指这段时间 cpu 暂时不会理它了,但另一个部件比如硬盘则在努力地为它服务。cpu 与硬盘间是并发的。如果把线程视作为一个 job,这一 job 由 cpu 与硬盘交替协作完成,当在 cpu 上是 waiting 时,在硬盘上却处于 running,只是我们在操作系统层面讨论线程状态时通常是围绕着 cpu 这一中心去述说的。而当 I/O 完成时,则用一种叫中断(interrupt)的机制来通知 cpu:也即所谓的“中断驱动(interrupt-driven)”,现代操作系统基本都采用这一机制。某种意义上,这也是控制反转(IoC)机制的一种体现,cpu不用反复去询问硬盘,这也是所谓的“好莱坞原则”—Don’t call us, we will call you.好莱坞的经纪人经常对演员们说:“别打电话给我,(有戏时)我们会打电话给你。”在这里,硬盘与 cpu 的互动机制也是类似,硬盘对 cpu 说:”别老来问我 IO 做完了没有,完了我自然会通知你的“当然了,cpu 还是要不断地检查中断,就好比演员们也要时刻注意接听电话,不过这总好过不断主动去询问,毕竟绝大多数的询问都将是徒劳的。cpu 会收到一个比如说来自硬盘的中断信号,并进入中断处理例程,手头正在执行的线程因此被打断,回到 ready 队列。而先前因 I/O 而waiting 的线程随着 I/O 的完成也再次回到 ready 队列,这时 cpu 可能会选择它来执行。另一方面,所谓的时间分片轮转本质上也是由一个定时器定时中断来驱动的,可以使线程从 running 回到 ready 状态.
现在我们再看一下 Java 中定义的线程状态,嘿,它也有 BLOCKED(阻塞),也有 WAITING(等待),甚至它还更细,还有TIMED_WAITING:
现在问题来了,进行阻塞式 I/O 操作时,Java 的线程状态究竟是什么?是 BLOCKED?还是 WAITING?
可能你已经猜到,既然放到 RUNNABLE 这一主题下讨论,其实状态还是 RUNNABLE。我们也可以通过一些测试来验证这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 >
>
> public void testInBlockedIOState() throws InterruptedException {
> Scanner in = new Scanner(System.in);
> // 创建一个名为“输入输出”的线程t
> Thread t = new Thread(new Runnable() {
>
> public void run() {
> try {
> // 命令行中的阻塞读
> String input = in.nextLine();
> System.out.println(input);
> } catch (Exception e) {
> e.printStackTrace();
> } finally {
> IOUtils.closeQuietly(in);
> }
> }
> }, "输入输出"); // 线程的名字
>
> // 启动
> t.start();
>
> // 确保run已经得到执行
> Thread.sleep(100);
>
> // 状态为RUNNABLE
> assertThat(t.getState()).isEqualTo(Thread.State.RUNNABLE);
> }
>
在最后的语句上加一断点,监控上也反映了这一点:
网络阻塞时同理,比如socket.accept,我们说这是一个“阻塞式(blocked)”式方法,但线程状态还是 RUNNABLE。
当然,Java 很早就引入了所谓 nio(新的IO)包,至于用 nio 时线程状态究竟是怎样的,这里就不再一一具体去分析了。
至少我们看到了,进行传统上的 IO 操作时,口语上我们也会说“阻塞”,但这个“阻塞”与线程的 BLOCKED 状态是两码事!
如何看待RUNNABLE状态?
要分两个层面看待,JVM层面和OS层面
当进行阻塞式的 IO 操作时,或许底层的操作系统线程确实处在阻塞状态,但我们关心的是 JVM 的线程状态。
JVM 并不关心底层的实现细节,什么时间分片也好,什么 IO 时就要切换也好,它并不关心。
前面说到,“处于 runnable 状态下的线程正在* Java 虚拟机中执行,但它可能正在等待*来自于操作系统的其它资源,比如处理器。”
JVM 把那些都视作资源,cpu 也好,硬盘,网卡也罢,有东西在为线程服务,它就认为线程在“执行”。
处于 IO 阻塞,只是说 cpu 不执行线程了,但网卡可能还在监听呀,虽然可能暂时没有收到数据:
所以 JVM 认为线程还在执行。而操作系统的线程状态是围绕着 cpu 这一核心去述说的,这与 JVM 的侧重点是有所不同的。
前面我们也强调了“Java 线程状态的改变通常只与自身显式引入的机制有关”,如果 JVM 中的线程状态发生改变了,通常是自身机制引发的。
比如 synchronize 机制有可能让线程进入BLOCKED 状态,sleep,wait等方法则可能让其进入 WATING 之类的状态。
它与传统的线程状态的对应可以如下来看:
BLOCKED 和 WAITING 的区别
虽然 BLOCKED 和 WAITING 都有等待的含义,但二者有着本质的区别,首先它们状态形成的调用方法不同,其次 BLOCKED 可以理解为当前线程还处于活跃状态,只是在阻塞等待其他线程使用完某个锁资源;而 WAITING 则是因为自身调用了 Object.wait() 或着是 Thread.join() 又或者是 LockSupport.park() 而进入等待状态,只能等待其他线程执行某个特定的动作才能被继续唤醒,比如当线程因为调用了 Object.wait() 而进入 WAITING 状态之后,则需要等待另一个线程执行 Object.notify() 或 Object.notifyAll() 才能被唤醒。
start() 和 run() 的区别
首先从 Thread 源码来看,start() 方法属于 Thread 自身的方法,并且使用了 synchronized 来保证线程安全,源码如下:
1 | public synchronized void start() { |
run() 方法为 Runnable 的抽象方法,必须由调用类重写此方法,重写的 run() 方法其实就是此线程要执行的业务方法
从执行的效果来说,start() 方法可以开启多线程,让线程从 NEW 状态转换成 RUNNABLE 状态,而 run() 方法只是一个普通的方法。
其次,它们可调用的次数不同,start() 方法不能被多次调用,否则会抛出 java.lang.IllegalStateException;而 run() 方法可以进行多次调用,因为它只是一个普通的方法而已。
线程优先级
在 Thread 源码中和线程优先级相关的属性有 3 个:
1 | // 线程可以拥有的最小优先级 |
线程的优先级可以理解为线程抢占 CPU 时间片的概率,优先级越高的线程优先执行的概率就越大,但并不能保证优先级高的线程一定先执行。
在程序中我们可以通过 Thread.setPriority() 来设置优先级,setPriority() 源码如下:
1 | public final void setPriority(int newPriority) { |
join
在一个线程中调用 other.join() ,这时候当前线程会让出执行权给 other 线程,直到 other 线程执行完或者过了超时时间之后再继续执行当前线程,join() 源码如下:
1 | public final synchronized void join(long millis) |
从源码中可以看出 join() 方法底层还是通过 wait() 方法来实现的。
yield
看 Thread 的源码可以知道 yield() 为本地方法,也就是说 yield() 是由 C 或 C++ 实现的,源码如下:
1 | public static native void yield(); |
yield() 方法表示给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。
比如我们执行这段包含了 yield() 方法的代码,如下所示:
1 | public static void main(String[] args) throws InterruptedException { |
当我们把这段代码执行多次之后会发现,每次执行的结果都不相同,这是因为 yield() 执行非常不稳定,线程调度器不一定会采纳 yield() 出让 CPU 使用权的建议,从而导致了这样的结果。
ThreadPool
线程池是为了避免线程频繁的创建和销毁带来的性能消耗,而建立的一种池化技术,它是把已创建的线程放入“池”中,当有任务来临时就可以重用已有的线程,无需等待创建的过程,这样就可以有效提高程序的响应速度。但如果要说线程池的话一定离不开 ThreadPoolExecutor ,在阿里巴巴的《Java 开发手册》中是这样规定线程池的:
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的读者更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
其实当我们去看 Executors 的源码会发现,Executors.newFixedThreadPool()、Executors.newSingleThreadExecutor() 和 Executors.newCachedThreadPool() 等方法的底层都是通过 ThreadPoolExecutor 实现的,所以本课时我们就重点来了解一下 ThreadPoolExecutor 的相关知识,比如它有哪些核心的参数?它是如何工作的?
第 1 个参数:corePoolSize 表示线程池的常驻核心线程数。如果设置为 0,则表示在没有任何任务时,销毁线程池;如果大于 0,即使没有任务时也会保证线程池的线程数量等于此值。但需要注意,此值如果设置的比较小,则会频繁的创建和销毁线程(创建和销毁的原因会在本课时的下半部分讲到);如果设置的比较大,则会浪费系统资源,所以开发者需要根据自己的实际业务来调整此值。
第 2 个参数:maximumPoolSize 表示线程池在任务最多时,最大可以创建的线程数。官方规定此值必须大于 0,也必须大于等于 corePoolSize,此值只有在任务比较多,且不能存放在任务队列时,才会用到。
第 3 个参数:keepAliveTime 表示线程的存活时间,当线程池空闲时并且超过了此时间,多余的线程就会销毁,直到线程池中的线程数量销毁的等于 corePoolSize 为止,如果 maximumPoolSize 等于 corePoolSize,那么线程池在空闲的时候也不会销毁任何线程。
第 4 个参数:unit 表示存活时间的单位,它是配合 keepAliveTime 参数共同使用的。
第 5 个参数:workQueue 表示线程池执行的任务队列,当线程池的所有线程都在处理任务时,如果来了新任务就会缓存到此任务队列中排队等待执行。
第 6 个参数:threadFactory 表示线程的创建工厂,此参数一般用的比较少,我们通常在创建线程池时不指定此参数,它会使用默认的线程创建工厂的方法来创建线程,源代码如下
1 | public ThreadPoolExecutor(int corePoolSize, |
我们也可以自定义一个线程工厂,通过实现 ThreadFactory 接口来完成,这样就可以自定义线程的名称或线程执行的优先级了。
第 7 个参数:RejectedExecutionHandler 表示指定线程池的拒绝策略,当线程池的任务已经在缓存队列 workQueue 中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。
线程池的工作流程要从它的执行方法 execute() 说起,源码如下:
1 | public void execute(Runnable command) { |
我们也可以自定义一个线程工厂,通过实现 ThreadFactory 接口来完成,这样就可以自定义线程的名称或线程执行的优先级了。
第 7 个参数:RejectedExecutionHandler 表示指定线程池的拒绝策略,当线程池的任务已经在缓存队列 workQueue 中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。
线程池的工作流程要从它的执行方法 execute() 说起,源码如下:
1 | public void execute(Runnable command) { |
本课时的这道面试题考察的是你对于线程池和 ThreadPoolExecutor 的掌握程度,也属于 Java 的基础知识,几乎所有的面试都会被问到,其中线程池任务执行的主要流程,可以参考以下流程图:
Tips
ThreadPoolExecutor 的执行方法有几种?它们有什么区别?
execute() VS submit()
execute() 和 submit() 都是用来执行线程池任务的,它们最主要的区别是,submit() 方法可以接收线程池执行的返回值,而 execute() 不能接收返回值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 > ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 10, 10L,
> TimeUnit.SECONDS, new LinkedBlockingQueue(20));
> // execute 使用
> executor.execute(new Runnable() {
>
> public void run() {
> System.out.println("Hello, execute.");
> }
> });
>
> // submit 使用
> Future<String> future = executor.submit(new Callable<String>() {
>
> public String call() throws Exception {
> System.out.println("Hello, submit.");
> return "Success";
> }
> });
>
> System.out.println(future.get());
>
>
程序执行结果如下
1
2
3
4
5
6 > Hello, submit.
>
> Hello, execute.
>
> Success
>
从以上结果可以看出 submit() 方法可以配合 Futrue 来接收线程执行的返回值。它们的另一个区别是 execute() 方法属于 Executor 接口的方法,而 submit() 方法则是属于 ExecutorService 接口的方法,它们的继承关系如下图所示:
什么是线程的拒绝策略?
当线程池中的任务队列已经被存满,再有任务添加时会先判断当前线程池中的线程数是否大于等于线程池的最大值,如果是,则会触发线程池的拒绝策略。
拒绝策略的分类有哪些?
Java 自带的拒绝策略有 4 种:
- AbortPolicy,终止策略,线程池会抛出异常并终止执行,它是默认的拒绝策略;
- CallerRunsPolicy,把任务交给当前线程来执行;
- DiscardPolicy,忽略此任务(最新的任务);
- DiscardOldestPolicy,忽略最早的任务(最先加入队列的任务)。
1 | ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 10, |
1 | pool-1-thread-1 |
可以看出当第 6 个任务来的时候,线程池则执行了 AbortPolicy 拒绝策略,抛出了异常。因为队列最多存储 2 个任务,最大可以创建 3 个线程来执行任务(2+3=5),所以当第 6 个任务来的时候,此线程池就“忙”不过来了。
如何自定义拒绝策略?
自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可,如下代码所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 > ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 10,
> TimeUnit.SECONDS, new LinkedBlockingQueue<>(2),
> new RejectedExecutionHandler() { // 添加自定义拒绝策略
>
> public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
> // 业务处理方法
> System.out.println("执行自定义拒绝策略");
> }
> });
> for (int i = 0; i < 6; i++) {
> executor.execute(() -> {
> System.out.println(Thread.currentThread().getName());
> });
> }
>
可以看出线程池执行了自定义的拒绝策略,我们可以在 rejectedExecution 中添加自己业务处理的代码。
ThreadPoolExecutor 能不能实现扩展?如何实现扩展?
ThreadPoolExecutor 的扩展主要是通过重写它的 beforeExecute() 和 afterExecute() 方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间,如下代码所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50 > public class ThreadPoolExtend {
> public static void main(String[] args) throws ExecutionException, InterruptedException {
> // 线程池扩展调用
> MyThreadPoolExecutor executor = new MyThreadPoolExecutor(2, 4, 10,
> TimeUnit.SECONDS, new LinkedBlockingQueue());
> for (int i = 0; i < 3; i++) {
> executor.execute(() -> {
> Thread.currentThread().getName();
> });
> }
> }
> /**
> * 线程池扩展
> */
> static class MyThreadPoolExecutor extends ThreadPoolExecutor {
> // 保存线程执行开始时间
> private final ThreadLocal<Long> localTime = new ThreadLocal<>();
> public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
> TimeUnit unit, BlockingQueue<Runnable> workQueue) {
> super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
> }
> /**
> * 开始执行之前
> * @param t 线程
> * @param r 任务
> */
>
> protected void beforeExecute(Thread t, Runnable r) {
> Long sTime = System.nanoTime(); // 开始时间 (单位:纳秒)
> localTime.set(sTime);
> System.out.println(String.format("%s | before | time=%s",
> t.getName(), sTime));
> super.beforeExecute(t, r);
> }
> /**
> * 执行完成之后
> * @param r 任务
> * @param t 抛出的异常
> */
>
> protected void afterExecute(Runnable r, Throwable t) {
> Long eTime = System.nanoTime(); // 结束时间 (单位:纳秒)
> Long totalTime = eTime - localTime.get(); // 执行总时间
> System.out.println(String.format("%s | after | time=%s | 耗时:%s 毫秒",
> Thread.currentThread().getName(), eTime, (totalTime / 1000000.0)));
> super.afterExecute(r, t);
> }
> }
> }
>
以上程序的执行结果如下所示:
1
2
3
4
5
6
7 > pool-1-thread-1 | before | time=4570298843700
> pool-1-thread-2 | before | time=4570298840000
> pool-1-thread-1 | after | time=4570327059500 | 耗时:28.2158 毫秒
> pool-1-thread-2 | after | time=4570327138100 | 耗时:28.2981 毫秒
> pool-1-thread-1 | before | time=4570328467800
> pool-1-thread-1 | after | time=4570328636800 | 耗时:0.169 毫秒
>
独占锁、共享锁、更新锁,乐观锁、悲观锁
从数据库系统的角度来看,锁分为以下三种类型
独占锁(Exclusive Lock)
独占锁锁定的资源只允许进行锁定操作的程序使用,其它任何对它的操作均不会被接受。执行数据更新命令,即INSERT、 UPDATE 或DELETE 命令时,SQL Server 会自动使用独占锁。但当对象上有其它锁存在时,无法对其加独占锁。独占锁一直到事务结束才能被释放。
共享锁(Shared Lock)
共享锁锁定的资源可以被其它用户读取,但其它用户不能修改它。在SELECT 命令执行时,SQL Server 通常会对对象进行共享锁锁定。通常加共享锁的数据页被读取完毕后,共享锁就会立即被释放。
更新锁(Update Lock)
更新锁是为了防止死锁而设立的。当SQL Server 准备更新数据时,它首先对数据对象作更新锁锁定,这样数据将不能被修改,但可以读取。等到SQL Server 确定要进行更新数据操作时,它会自动将更新锁换为独占锁。但当对象上有其它锁存在时,无法对其作更新锁锁定。
从程序员的角度看,锁分为以下两种类型
悲观锁(Pessimistic Lock)
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
乐观锁(Optimistic Lock)
相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。
而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
synchronized & ReentrantLock
在 JDK 1.5 之前共享对象的协调机制只有 synchronized 和 volatile,在 JDK 1.5 中增加了新的机制 ReentrantLock,该机制的诞生并不是为了替代 synchronized,而是在 synchronized 不适用的情况下,提供一种可以选择的高级功能。
synchronized 属于独占式悲观锁,是通过 JVM 隐式实现的,synchronized 只允许同一时刻只有一个线程操作资源。
在 Java 中每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
ReentrantLock 是 Lock 的默认实现方式之一,它是基于 AQS(Abstract Queued Synchronizer,队列同步器)实现的,它默认是通过非公平锁实现的,在它的内部有一个 state 的状态字段用于表示锁是否被占用,如果是 0 则表示锁未被占用,此时线程就可以把 state 改为 1,并成功获得锁,而其他未获得锁的线程只能去排队等待获取锁资源。
synchronized 和 ReentrantLock 都提供了锁的功能,具备互斥性和不可见性。在 JDK 1.5 中 synchronized 的性能远远低于 ReentrantLock,但在 JDK 1.6 之后 synchronized 的性能略低于 ReentrantLock,它的区别如下:
synchronized 是 JVM 隐式实现的,而 ReentrantLock 是 Java 语言提供的 API;
ReentrantLock 可设置为公平锁,而 synchronized 却不行;
ReentrantLock 只能修饰代码块,而 synchronized 可以用于修饰方法、修饰代码块等;
ReentrantLock 需要手动加锁和释放锁,如果忘记释放锁,则会造成资源被永久占用,而 synchronized 无需手动释放锁;
ReentrantLock 可以知道是否成功获得了锁,而 synchronized 却不行。
synchronized 和 ReentrantLock 是比线程池还要高频的面试问题,因为它包含了更多的知识点,且涉及到的知识点更加深入,对面试者的要求也更高,前面我们简要地介绍了 synchronized 和 ReentrantLock 的概念及执行原理,但很多大厂会更加深入的追问更多关于它们的实现细节,比如:
ReentrantLock 的具体实现细节是什么?
JDK 1.6 时锁做了哪些优化?
首先来看 ReentrantLock 的两个构造函数:
1 | public ReentrantLock() { |
无参的构造函数创建了一个非公平锁,用户也可以根据第二个构造函数,设置一个 boolean 类型的值,来决定是否使用公平锁来实现线程的调度。
公平锁 VS 非公平锁
公平锁的含义是线程需要按照请求的顺序来获得锁;而非公平锁则允许“插队”的情况存在,所谓的“插队”指的是,线程在发送请求的同时该锁的状态恰好变成了可用,那么此线程就可以跳过队列中所有排队的线程直接拥有该锁。
而公平锁由于有挂起和恢复所以存在一定的开销,因此性能不如非公平锁,所以 ReentrantLock 和 synchronized 默认都是非公平锁的实现方式。
ReentrantLock 是通过 lock() 来获取锁,并通过 unlock() 释放锁,使用代码如下:
1 | Lock lock = new ReentrantLock(); |
ReentrantLock 中的 lock() 是通过 sync.lock() 实现的,但 Sync 类中的 lock() 是一个抽象方法,需要子类 NonfairSync 或 FairSync 去实现,NonfairSync 中的 lock() 源码如下:
1 | final void lock() { |
FairSync 中的 lock() 源码如下:
1 | final void lock() { |
可以看出非公平锁比公平锁只是多了一行 compareAndSetState 方法,该方法是尝试将 state 值由 0 置换为 1,如果设置成功的话,则说明当前没有其他线程持有该锁,不用再去排队了,可直接占用该锁,否则,则需要通过 acquire 方法去排队。
acquire 源码如下:
1 | public final void acquire(int arg) { |
tryAcquire 方法尝试获取锁,如果获取锁失败,则把它加入到阻塞队列中,来看 tryAcquire 的源码:
1 | protected final boolean tryAcquire(int acquires) { |
对于此方法来说,公平锁比非公平锁只多一行代码 !hasQueuedPredecessors(),它用来查看队列中是否有比它等待时间更久的线程,如果没有,就尝试一下是否能获取到锁,如果获取成功,则标记为已经被占用。
如果获取锁失败,则调用 addWaiter 方法把线程包装成 Node 对象,同时放入到队列中,但 addWaiter 方法并不会尝试获取锁,acquireQueued 方法才会尝试获取锁,如果获取失败,则此节点会被挂起,源码如下:
1 | /** |
该方法会使用 for(;;) 无限循环的方式来尝试获取锁,若获取失败,则调用 shouldParkAfterFailedAcquire 方法,尝试挂起当前线程,源码如下:
1 | /** |
线程入列被挂起的前提条件是,前驱节点的状态为 SIGNAL,SIGNAL 状态的含义是后继节点处于等待状态,当前节点释放锁后将会唤醒后继节点。所以在上面这段代码中,会先判断前驱节点的状态,如果为 SIGNAL,则当前线程可以被挂起并返回 true;如果前驱节点的状态 >0,则表示前驱节点取消了,这时候需要一直往前找,直到找到最近一个正常等待的前驱节点,然后把它作为自己的前驱节点;如果前驱节点正常(未取消),则修改前驱节点状态为 SIGNAL。
到这里整个加锁的流程就已经走完了,最后的情况是,没有拿到锁的线程会在队列中被挂起,直到拥有锁的线程释放锁之后,才会去唤醒其他的线程去获取锁资源,整个运行流程如下图所示
unlock 相比于 lock 来说就简单很多了,源码如下:
1 | public void unlock() { |
锁的释放流程为,先调用 tryRelease 方法尝试释放锁,如果释放成功,则查看头结点的状态是否为 SIGNAL,如果是,则唤醒头结点的下个节点关联的线程;如果释放锁失败,则返回 false。
tryRelease 源码如下:
1 | /** |
在 tryRelease 方法中,会先判断当前的线程是不是占用锁的线程,如果不是的话,则会抛出异常;如果是的话,则先计算锁的状态值 getState() - releases 是否为 0,如果为 0,则表示可以正常的释放锁,然后清空独占的线程,最后会更新锁的状态并返回执行结果。
JDK 1.6 锁优化
自适应自旋锁
JDK 1.5 在升级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化上下了很大功夫,比如实现了自适应式自旋锁、锁升级等。
JDK 1.6 引入了自适应式自旋锁意味着自旋的时间不再是固定的时间了,比如在同一个锁对象上,如果通过自旋等待成功获取了锁,那么虚拟机就会认为,它下一次很有可能也会成功 (通过自旋获取到锁),因此允许自旋等待的时间会相对的比较长,而当某个锁通过自旋很少成功获得过锁,那么以后在获取该锁时,可能会直接忽略掉自旋的过程,以避免浪费 CPU 的资源,这就是自适应自旋锁的功能。
锁升级
锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。
偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到另一个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。但如果在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。
轻量锁是相对于重量锁而言的,在 JDK 1.6 之前,synchronized 是通过操作系统的互斥量(mutex lock)来实现的,这种实现方式需要在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为重量锁。
而轻量锁是通过比较并交换(CAS,Compare and Swap)来实现的,它对比的是线程和对象的 Mark Word(对象头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。
synchronized 刚开始为偏向锁,随着锁竞争越来越激烈,会升级为轻量级锁和重量级锁。如果大多数锁被不同的线程所争抢就不建议使用偏向锁了。
对锁的理解
在并发编程中有两个重要的概念:线程和锁,多线程是一把双刃剑,它在提高程序性能的同时,也带来了编码的复杂性,对开发者的要求也提高了一个档次。而锁的出现就是为了保障多线程在同时操作一组资源时的数据一致性,当我们给资源加上锁之后,只有拥有此锁的线程才能操作此资源,而其他线程只能排队等待使用此锁。
如何手动模拟一个死锁?谈谈你对锁的理解。
典型回答
死锁是指两个线程同时占用两个资源,又在彼此等待对方释放锁资源,如下图所示
死锁的代码演示如下:
1 | import java.util.concurrent.TimeUnit; |
以上程序执行结果如下:
1 | 获取 lock1 成功 |
可以看出当我们使用线程一拥有锁 lock1 的同时试图获取 lock2,而线程二在拥有 lock2 的同时试图获取 lock1,这样就会造成彼此都在等待对方释放资源,于是就形成了死锁。
锁是指在并发编程中,当有多个线程同时操作一个资源时,为了保证数据操作的正确性,我们需要让多线程排队一个一个地操作此资源,而这个过程就是给资源加锁和释放锁的过程,就好像去公共厕所一样,必须一个一个排队使用,并且在使用时需要锁门和开门一样。
考点分析
锁的概念不止出现在 Java 语言中,比如乐观锁和悲观锁其实很早就存在于数据库中了。锁的概念其实不难理解,但要真正地了解锁的原理和实现过程,才能打动面试官。
和锁相关的面试问题,还有以下几个:
- 什么是乐观锁和悲观锁?它们的应用都有哪些?乐观锁有什么问题?
- 什么是可重入锁?用代码如何实现?它的实现原理是什么?
- 什么是共享锁和独占锁?
知识扩展
1. 悲观锁和乐观锁
悲观锁指的是数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。
我们来看一下悲观锁的实现流程,以 synchronized 为例,代码如下:
1 | public class LockExample { |
我们使用反编译工具查到的结果如下:
1 | Compiled from "LockExample.java" |
可以看出被 synchronized 修饰的代码块,在执行之前先使用 monitorenter 指令加锁,然后在执行结束之后再使用 monitorexit 指令释放锁资源,在整个执行期间此代码都是锁定的状态,这就是典型悲观锁的实现流程。
乐观锁和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测。
Java 中的乐观锁大部分都是通过 CAS(Compare And Swap,比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。
CAS 可能会造成 ABA 的问题,ABA 问题指的是,线程拿到了最初的预期原值 A,然而在将要进行 CAS 的时候,被其他线程抢占了执行权,把此值从 A 变成了 B,然后其他线程又把此值从 B 变成了 A,然而此时的 A 值已经并非原来的 A 值了,但最初的线程并不知道这个情况,在它进行 CAS 的时候,只对比了预期原值为 A 就进行了修改,这就造成了 ABA 的问题。
以警匪剧为例,假如某人把装了 100W 现金的箱子放在了家里,几分钟之后要拿它去赎人,然而在趁他不注意的时候,进来了一个小偷,用空箱子换走了装满钱的箱子,当某人进来之后看到箱子还是一模一样的,他会以为这就是原来的箱子,就拿着它去赎人了,这种情况肯定有问题,因为箱子已经是空的了,这就是 ABA 的问题。
ABA 的常见处理方式是添加版本号,每次修改之后更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了 ABA 的问题。
JDK 在 1.5 时提供了 AtomicStampedReference 类也可以解决 ABA 的问题,此类维护了一个“版本号” Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。
相关源码如下:
1 | public class AtomicStampedReference<V> { |
可以看出它在修改时会进行原值比较和版本号比较,当比较成功之后会修改值并修改版本号。
小贴士:乐观锁有一个优点,它在提交的时候才进行锁定的,因此不会造成死锁。
2. 可重入锁
可重入锁也叫递归锁,指的是同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁。
下面我们用 synchronized 来演示一下什么是可重入锁,代码如下:
1 | public class LockExample { |
以上代码的执行结果如下:
1 | main:执行 reentrantA |
从结果可以看出 reentrantA 方法和 reentrantB 方法的执行线程都是“main” ,我们调用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。
可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为 0,当被线程占用和重入时分别加 1,当锁被释放时计数器减 1,直到减到 0 时表示此锁为空闲状态。
3. 共享锁和独占锁
只能被单线程持有的锁叫独占锁,可以被多线程持有的锁叫共享锁。
独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 synchronized 就是独占锁,而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。
独占锁可以理解为悲观锁,当每次访问资源时都要加上互斥锁,而共享锁可以理解为乐观锁,它放宽了加锁的条件,允许多线程同时访问该资源。
小结
悲观锁和乐观锁,悲观锁的典型应用为 synchronized,它的特性为独占式互斥锁;而乐观锁相比于悲观锁而言,拥有更好的性能,但乐观锁可能会导致 ABA 的问题,常见的解决方案是添加版本号来防止 ABA 问题的发生。同时,还讲了可重入锁,在 Java 中,synchronized 和 ReentrantLock 都是可重入锁。最后,讲了独占锁和共享锁,其中独占锁可以理解为悲观锁,而共享锁可以理解为乐观锁。
我对共享锁和排它锁有不同理解共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据。排他锁,又称为写锁、独占锁。获准排他锁后,既能读数据,又能修改数据.
有个问题不太明白,cas既然是原子操作,为什么在a线程取到期望值之后没有比较,而被b线程抢占了执行权,我理解的原子操作,在执行过程中不会受任何的干扰,不知道哪里理解不太对,还请老师指点
答案: CAS 保证了原子性,但存在 ABA 的问题。可以理解为原子操作只能保证一个步骤执行的完整性,但ABA问题是组合操作,所以会存在问题。
Java Clone
Java中的克隆存在两种 深克隆和浅克隆
浅克隆
深克隆
在 Java 语言中要实现克隆则需要实现 Cloneable 接口,并重写 Object 类中的 clone() 方法,实现代码如下:
1 | public class CloneExample { |
以上程序执行的结果为:
1 | p2:Java |
Tips
在 java.lang.Object 中对 clone() 方法的约定有哪些?
要想真正的了解克隆,首先要从它的源码入手,代码如下:
复制代码
1 | /** |
从以上源码的注释信息中我们可以看出,Object 对 clone() 方法的约定有三条:
- 对于所有对象来说,x.clone() !=x 应当返回 true,因为克隆对象与原对象不是同一个对象;
- 对于所有对象来说,x.clone().getClass() == x.getClass() 应当返回 true,因为克隆对象与原对象的类型是一样的;
- 对于所有对象来说,x.clone().equals(x) 应当返回 true,因为使用 equals 比较时,它们的值都是相同的。
除了注释信息外,我们看 clone() 的实现方法,发现 clone() 是使用 native 修饰的本地方法,因此执行的性能会很高,并且它返回的类型为 Object,因此在调用克隆之后要把对象强转为目标类型才行。
Arrays.copyOf() 是深克隆还是浅克隆?
如果是数组类型,我们可以直接使用 Arrays.copyOf() 来实现克隆,实现代码如下:
复制代码
1 | People[] o1 = {new People(1, "Java")}; |
以上程序的执行结果为:
复制代码
1 | o1:Jdk |
从结果可以看出,我们在修改克隆对象的第一个元素之后,原型对象的第一个元素也跟着被修改了,这说明 Arrays.copyOf() 其实是一个浅克隆。
因为数组比较特殊数组本身就是引用类型,因此在使用 Arrays.copyOf() 其实只是把引用地址复制了一份给克隆对象,如果修改了它的引用对象,那么指向它的(引用地址)所有对象都会发生改变,因此看到的结果是,修改了克隆对象的第一个元素,原型对象也跟着被修改了。
深克隆的实现方式有几种?
深克隆的实现方式有很多种,大体可以分为以下几类:
- 所有对象都实现克隆方法;
- 通过构造方法实现深克隆;
- 使用 JDK 自带的字节流实现深克隆;
- 使用第三方工具实现深克隆,比如 Apache Commons Lang;
- 使用 JSON 工具类实现深克隆,比如 Gson、FastJSON 等。
接下来我们分别来实现以上这些方式,在开始之前先定义一个公共的用户类,代码如下:
复制代码
1 | /** |
可以看出在 People 对象中包含了一个引用对象 Address。
1.所有对象都实现克隆
这种方式我们需要修改 People 和 Address 类,让它们都实现 Cloneable 的接口,让所有的引用对象都实现克隆,从而实现 People 类的深克隆,代码如下:
复制代码
1 | public class CloneExample { |
以上程序的执行结果为:
复制代码
1 | p1:西安 p2:北京 |
从结果可以看出,当我们修改了原型对象的引用属性之后,并没有影响克隆对象,这说明此对象已经实现了深克隆。
2.通过构造方法实现深克隆
《Effective Java》 中推荐使用构造器(Copy Constructor)来实现深克隆,如果构造器的参数为基本数据类型或字符串类型则直接赋值,如果是对象类型,则需要重新 new 一个对象,实现代码如下:
复制代码
1 | public class SecondExample { |
以上程序的执行结果为:
复制代码
1 | p1:西安 p2:北京 |
从结果可以看出,当我们修改了原型对象的引用属性之后,并没有影响克隆对象,这说明此对象已经实现了深克隆。
3.通过字节流实现深克隆
通过 JDK 自带的字节流实现深克隆的方式,是先将要原型对象写入到内存中的字节流,然后再从这个字节流中读出刚刚存储的信息,来作为一个新的对象返回,那么这个新对象和原型对象就不存在任何地址上的共享,这样就实现了深克隆,代码如下:
复制代码
1 | import java.io.*; |
以上程序的执行结果为:
复制代码
1 | p1:西安 p2:北京 |
此方式需要注意的是,由于是通过字节流序列化实现的深克隆,因此每个对象必须能被序列化,必须实现 Serializable 接口,标识自己可以被序列化,否则会抛出异常 (java.io.NotSerializableException)。
4.通过第三方工具实现深克隆
本课时使用 Apache Commons Lang 来实现深克隆,实现代码如下:
复制代码
1 | import org.apache.commons.lang3.SerializationUtils; |
以上程序的执行结果为:
复制代码
1 | p1:西安 p2:北京 |
可以看出此方法和第三种实现方式类似,都需要实现 Serializable 接口,都是通过字节流的方式实现的,只不过这种实现方式是第三方提供了现成的方法,让我们可以直接调用。
5.通过 JSON 工具类实现深克隆
本课时我们使用 Google 提供的 JSON 转化工具 Gson 来实现,其他 JSON 转化工具类也是类似的,实现代码如下:
复制代码
1 | import com.google.gson.Gson; |
以上程序的执行结果为:
复制代码
1 | p1:西安 p2:北京 |
使用 JSON 工具类会先把对象转化成字符串,再从字符串转化成新的对象,因为新对象是从字符串转化而来的,因此不会和原型对象有任何的关联,这样就实现了深克隆,其他类似的 JSON 工具类实现方式也是一样的。
Java 中的克隆为什么要设计成,既要实现空接口 Cloneable,还要重写 Object 的 clone() 方法?
从源码中可以看出 Cloneable 接口诞生的比较早,JDK 1.0 就已经存在了,因此从那个时候就已经有克隆方法了,那我们怎么来标识一个类级别对象拥有克隆方法呢?克隆虽然重要,但我们不能给每个类都默认加上克隆,这显然是不合适的,那我们能使用的手段就只有这几个了:
- 在类上新增标识,此标识用于声明某个类拥有克隆的功能,像 final 关键字一样;
- 使用 Java 中的注解;
- 实现某个接口;
- 继承某个类。
先说第一个,为了一个重要但不常用的克隆功能, 单独新增一个类标识,这显然不合适;再说第二个,因为克隆功能出现的比较早,那时候还没有注解功能,因此也不能使用;第三点基本满足我们的需求,第四点和第一点比较类似,为了一个克隆功能需要牺牲一个基类,并且 Java 只能单继承,因此这个方案也不合适。采用排除法,无疑使用实现接口的方式是那时最合理的方案了,而且在 Java 语言中一个类可以实现多个接口。
那为什么要在 Object 中添加一个 clone() 方法呢?
因为 clone() 方法语义的特殊性,因此最好能有 JVM 的直接支持,既然要 JVM 直接支持,就要找一个 API 来把这个方法暴露出来才行,最直接的做法就是把它放入到一个所有类的基类 Object 中,这样所有类就可以很方便地调用到了。
QA
普通类 抽象类 接口区别
1.普通类可以实例化,接口都不能被实例化(它没有构造方法),抽象类如果要实例化,抽象类必须指向实现所有抽象方法的子类对象(抽象类可以直接实例化,直接重写自己的抽象方法),接口必须指向实现所有所有接口方法的类对象。
2.抽象类要被子类继承,接口要被子类实现。
3.接口只能做方法的声明,抽象类可以做方法的声明,也可以做方法的实现。
4.接口里定义的变量只能是公共的静态常量,抽象类中定义的变量是普通变量。
5.抽象类里的抽象方法必须全部被子类所实现,如果子类不能全部实现父类的抽象方法,那么该子类只能是抽象类。同样,一个实现接口的时候,如果不能全部实现接口方法,那么该类只能是抽象类。
6.抽象方法只能声明,不能实现。接口是设计的结果,抽象类是重构的结果。
7.抽象类里可以没有抽象方法。
8.如果一个类里有抽象方法,那么该类只能是抽象类。
9.抽象方法要被实现,所以不能是静态的,也不能是私有的。
10.接口可以继承接口,并可多继承接口,但类只能单继承。(重要啊)
11.接口中的常量:有固定的修饰符-publicstaticfinal(不能用private和protected修饰/本质上都
是static的而且是final类型的,不管加不加static修饰)。
12.接口中的抽象方法:有固定的修饰符-publicabstract。
注意:
①抽象类和接口都是用来抽象具体的对象的,但是接口的抽象级别更高。
②抽象类可以有具体的方法和属性,接口只能有抽象方法和静态常量。
③抽象类主要用来抽象级别,接口主要用来抽象功能。
④抽象类中,且不包含任何的实现,派生类必须覆盖它们。接口中所有方法都必须是未实现的。
⑤接口方法,访问权限必须是公共的public。
⑥接口内只能有公共方法,不能存在成员变量。
⑦接口内只能包含未被实现的方法,也叫抽象方法,但是不能用abstract关键字。
⑧抽象类的访问速度比接口要快,接口是稍微有点慢,因为它需要时间去寻找在类中实现的方法。
⑨抽象类,除了不能被实例化外,与普通java类没有任何区别。
⑩抽象类可以有main方法,接口没有main方法。
⑪抽象类可以用构造器,接口没有。
⑫抽象方法可以有public、protected和default这些修饰符,接口只能使用默认public。
⑬抽象类,添加新方法可以提供默认的实现,不需要改变原有代码。接口添加新方法,子类必须实现。
⑭抽象类的子类用extends关键字继承,接口用implements来实现。
可以作为GCRoot的对象有哪些?
1.Systemclass
2.JNIclass
3.JNIglobal
4.ThreadBlock
5.Thread.(examplenewThread().start())
6.Javalocal
7.NativeStack
8.JavastackFrame
spring中Bean的作用域
1.singleton:SpringIoC容器中只会存在一个共享的Bean实例,无论有多少个Bean引用它,始终指向同一对象。Singleton作用域是Spring中的缺省作用域。
2.prototype:每次通过Spring容器获取prototype定义的bean时,容器都将创建一个新的Bean实例,每个Bean实例都有自己的属性和状态,而singleton全局只有一个对象。
3.request:在一次Http请求中,容器会返回该Bean的同一实例。而对不同的Http请求则会产生新的Bean,而且该bean仅在当前HttpRequest内有效。
4.session:在一次HttpSession中,容器会返回该Bean的同一实例。而对不同的Session请求则会创建新的实例,该bean实例仅在当前Session内有效。
5.globalSession:在一个全局的HttpSession中,容器会返回该Bean的同一个实例,仅在使用portletcontext时有效。