腾讯 Java 高频面试题详解总结(转)

呱牛笔记

题目来源:https://github.com/resumejob/interview-questions

▲ 38 HashMap 与 ConcurrentHashMap 的实现原理是怎样的?ConcurrentHashMap 是如何保证线程安全的?


HashMap的实现:(参考:https://yuanrengu.com/2020/ba184259.html)

1、jdk1.7中底层是由数组(也有叫做“位桶”的)+链表实现;jdk1.8中底层是由数组+链表/红黑树实现

2、可以存储null键和null值,线程不安全。在HashMap中,null可以作为键,这样的键只有一个,但可以有一个或多个键所对应的值为null。当get()方法返回null值时,即可以表示HashMap中没有该key,也可以表示该key所对应的value为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在3、某个key,应该用containsKey()方法来判断。而在Hashtable中,无论是key还是value都不能为null。

4、初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂

5、扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

6、插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)

7、当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀

8、1.7中是先扩容后插入新值的,1.8中是先插值再扩容


HashMap的初始值还要考虑加载因子:

哈希冲突:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较。

加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。

空间换时间:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始大小,以降低哈希冲突的概率。


HashMap和Hashtable都是用hash算法来决定其元素的存储,因此HashMap和Hashtable的hash表包含如下属性:

1、容量(capacity):hash表中桶的数量

3、初始化容量(initial capacity):创建hash表时桶的数量,HashMap允许在构造器中指定初始化容量

3、尺寸(size):当前hash表中记录的数量

4、负载因子(load factor):负载因子等于“size/capacity”。负载因子为0,表示空的hash表,0.5表示半满的散列表,依此类推。轻负载的散列表具有冲突少、适宜插入与查询的特点(但是使用Iterator迭代元素时比较慢)


ConcurrentHashMap

1、底层采用分段的数组+链表实现,线程安全

2、通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)

3、Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术

4、有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁

5、扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容


ConcurrentHashMap为什么是线程安全的

ConcurrentHashMap是使用了锁分段技术来保证线程安全的。


锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。 


ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。


ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。


延展:HashTable

1、底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化

2、初始size为11,扩容:newsize = olesize*2+1

3、计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length


Hashtable与HashMap的区别

Hashtable是线程安全的,它的方法是同步的,可以直接用在多线程环境中。而HashMap则不是线程安全的,在多线程环境中,需要手动实现同步机制。


Hashtable与HashMap另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。


二叉查找树、完美平衡二叉树

二叉搜索树又叫二叉查找树、二叉排序树,我们先看一下典型的二叉搜索树,这样的二叉树有何规则特点呢?

二叉搜索树有如下几个特点:

1、节点的左子树小于节点本身

2、节点的右子树大于节点本身

3、左右子树同样为二叉搜索树


红黑树也是二叉查找树,二叉查找树这一数据结构并不难,而红黑树之所以难是难在它是自平衡的二叉查找树,在进行插入和删除等可能会破坏树的平衡的操作时,需要重新自处理达到平衡状态。

前面讲到红黑树能自平衡,它靠的是什么?三种操作:左旋、右旋和变色。

左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。 

右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。  

变色:结点的颜色由红变黑或由黑变红。


左旋只影响旋转结点和其右子树的结构,把右子树的结点往左子树挪了。

右旋只影响旋转结点和其左子树的结构,把左子树的结点往右子树挪了。

红黑树总是通过旋转和变色达到自平衡。

在10亿数据进行不到30次比较就能查找到目标时,不禁感叹编程之魅力!(参考:https://www.jianshu.com/p/e136ec79235c)

 

▲ 27 volatile 关键字解决了什么问题,它的实现原理是什么?


volatile 关键字解决了多线程间访问变量的可见性问题,怎么解决的呢?volatile作为java中的关键词之一,用以声明变量的值可能随时会别的线程修改,使用volatile修饰的变量会强制将修改的值立即写入主存,主存中值的更新会使缓存中的值失效(非volatile变量不具备这样的特性,非volatile变量的值会被缓存,线程A更新了这个值,线程B读取这个变量的值时可能读到的并不是是线程A更新后的值)。volatile会禁止指令重排。


volatile具有可见性、有序性,不具备原子性,也就是并不是线程安全的。

注意,volatile不具备原子性,这是volatile与java中的synchronized、java.util.concurrent.locks.Lock最大的功能差异,这一点在面试中也是非常容易问到的点。


下面来分别看下可见性、有序性、原子性:

原子性:如果你了解事务,那这个概念应该好理解。原子性通常指多个操作不存在只执行一部分的情况,如果全部执行完成那没毛病,如果只执行了一部分,那对不起,你得撤销(即事务中的回滚)已经执行的部分。

可见性:当多个线程访问同一个变量x时,线程1修改了变量x的值,线程1、线程2...线程n能够立即读取到线程1修改后的值。

有序性:即程序执行时按照代码书写的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。(本文不对指令重排作介绍,但不代表它不重要,它是理解JAVA并发原理时非常重要的一个概念)。


volatile适用场景

1、适用于对变量的写操作不依赖于当前值,对变量的读取操作不依赖于非volatile变量。

2、适用于读多写少的场景。

3、可用作状态标志。


JDK中volatie应用

JDK中ConcurrentHashMap的Entry的value和next被声明为volatile,AtomicLong中的value被声明为volatile。AtomicLong通过CAS原理(也可以理解为乐观锁)保证了原子性。

lock - read - load - use 表示一个线程需要 读取并使用共享变量 的过程。

assign - store - write - unlock 表示一个线程需要 写入共享变量 的过程。


volatile VS synchronized

volatilesynchronized修饰对象修饰变量修饰方法或代码段可见性11有序性11原子性01线程阻塞01对比这个表格,你会不会觉得synchronized完胜volatile,答案是否定的,volatile不会让线程阻塞,响应速度比synchronized高,这是它的优点。

主内存与工作内存,作用不一样:线程是 通过主内存 去进行线程间的 隐式通信 的,而线程对共享变量的写操作在 工作内存 中完成,由JVM控制 共享变量由工作内存写回到主内存的时机 

/** 
* 用volatile实现单例
 */
public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) { // 第①步
            synchronized (Singleton.class) {
                if (instance == null) { // 第②步
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}

静态内部类实现模式(线程安全,调用效率高,可以延时加载)

 1 public class SingletonDemo3 {
 2      
 3     private static class SingletonClassInstance{
 4         private static final SingletonDemo3 instance=new SingletonDemo3();
 5     }
 6      
 7     private SingletonDemo3(){}
 8      
 9     public static SingletonDemo3 getInstance(){
10         return SingletonClassInstance.instance;
11     }
12      
13 }

总结:

1、volatile能够防止指令重排序,但是不能保证线程安全

2、volatile不保证操作的原子性

3、被volatile修饰的变量满足内存可见性

4、synchronized关键字无法禁止指令序列内部进行重排序,能够确保同一个锁对象的不同指令序列串行执行

5、DCL必须使用volatile保证内存可见性和synchronized保证线程安全性


▲ 26 Java 中垃圾回收机制中如何判断对象需要回收?常见的 GC 回收算法有哪些?


答案参考:https://blog.csdn.net/yrwan95/article/details/82829186

一、如何确定某个对象是“垃圾”?

在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法。


这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。

  

为了解决这个问题,在Java中采取了可达性分析法。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。


二、典型的垃圾收集算法

在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。


1.Mark-Sweep(标记-清除)算法

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。


2.Copying(复制)算法

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。


3.Mark-Compact(标记-整理)算法(压缩法)

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。


4.Generational Collection(分代收集)算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。


目前大部分垃圾收集器对于新生代都采取复制算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。


而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法(压缩法)。


三、典型的垃圾收集器

垃圾收集算法是 内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpot(JDK 7)虚拟机提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。

1.Serial/Serial Old收集器 是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。

2.ParNew收集器 是Serial收集器的多线程版本,使用多个线程进行垃圾收集。

3.Parallel Scavenge收集器 是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。

4.Parallel Old收集器 是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。

5.CMS(Current Mark Sweep)收集器 是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。

6.G1收集器 是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

  

参考资料:《深入理解Java虚拟机》


▲ 26 synchronized 关键字底层是如何实现的?它与 Lock 相比优缺点分别是什么?


参考:《Java并发编程的艺术》

上下文切换:CPU在任务切换前会保存前一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次任务切换。

内存屏障:一组处理器指令,用于实现对内存操作的顺序限制。


Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。JDK 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。


处理器实现原子操作的方式:总线锁(锁住整个内存);缓存锁(在处理器内部缓存中实现原子操作,使其他处理器不能缓存 i 的缓存行)。

Java 实现原子操作的方式:锁和循环 CAS(Compare and Swap 比较并交换);CAS 利用了处理器的 CMPXCHG 指令(该指令是原子的)。

除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁。

 

锁的内存语义

众所周知,锁可以让临界区互斥执行;但锁有一个同样重要,但常常被忽视的功能:锁的内存语义。

当线程释放锁时,JVM会把该线程对应的本地内存中的共享变量刷新到主内存中

当线程获取锁时,JVM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量

对比锁释放-获取的内存语义与volatile写-读的内存语义,可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。


final的内存语义

JVM禁止编译器把final域的写重排序到构造函数之外(对普通域的写可能被重排序到构造函数之外!)

在一个线程中,初次读对象引用与初次读该对象包含的final域,JVM禁止处理器重排序这两个操作(这两个操作之间存在间接依赖,大多数处理器会遵守间接依赖,不会重排序这两个操作,但有少数处理器不遵守间接依赖关系,这个规则就是专门用来针对这种处理器的)


为什么final引用不能从构造函数内“逸出”

前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了(构造函数完成,对象引用才会产生)。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面示例代码:

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;
    public FinalReferenceEscapeExample () {
        i = 1;                              //1 写final域
        obj = this;                          //2 this引用在此“逸出”
    }
    public static void writer() {
        new FinalReferenceEscapeExample ();
    }
    public static void reader {
        if (obj != null) {                     //3
            int temp = obj.i;                 //4
        }
    }
}


基于锁的单例:

延迟初始化:推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。

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

上面的方法虽然线程安全,但用synchronized将导致性能开销。

一个“聪明”的技巧:双重检查锁定:

public class DoubleCheckLocking {
    private static Instance instance;
    public  static Instance getInstance() {
        if (instance == null) {
            synchronized(DoubleCheckLocking.class) {
                if (instance == null) {
                    instance = new Instance(); // 问题的根源出在这里
                }
            }
        }
        return instance;
    }
}

创建对象的过程instance = new Instance()可以分解为以下三步:

memory = allocate(); // 分配对象的内存空间

ctorInstance(memory); // 初始化对象

instance = memory; // 返回对象地址

初次访问对象

其中,2和3可能会被重排序!重排序之后变成了:分配对象内存空间,返回对象地址,初始化对象;(在单线程内,只要保证2排在4的前面执行,单线程内的执行结果就不会被改变,这个重排序就是被允许的)

解决方案:

1,利用volatile的内存语义来禁止重排序

private volatile static Instance instance;

根据volatile写的内存语义:volatile写之前的操作禁止被重排序到volatile写之后。这样上面2和3之间的重排序将会被禁止,问题根源得到解决。

2,利用类初始化的原子性

在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
        return InstanceHolder.instance ; // 这里将导致 InstanceHolder 类被初始化
    }
}

Java中的锁,Lock接口

void lock() 获取锁,调用该方法当前线程将会获取锁,当锁获取后,该方法将返回。

void lockInterruptibly() throws InterruptedException 可中断获取锁,与lock()方法不同之处在于该方法会响应中断,即在锁的获取过程中可以中断当前线程

boolean tryLock() 尝试非阻塞的获取锁,调用该方法立即返回,true表示获取到锁

boolean tryLock(long time,TimeUnit unit) throws InterruptedException 超时获取锁,以下情况会返回:时间内获取到了锁,时间内被中断,时间到了没有获取到锁。

void unlock() 释放锁

Condition newCondition() 获取等待通知组件


队列同步器

队列同步器AbstractQueuedSynchronizer(AQS)是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。


重入锁 ReentrantLock

重入锁 ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。

对于独占锁(Mutex),考虑如下场景:当一个线程调用Mutex的lock()方法获取锁之后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是Mutex在实现tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景,而在调用tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单地说,Mutex是一个不支持重进入的锁。


synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像Mutex由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。


ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。


锁获取的公平性问题

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该和锁的请求顺序一致,也就是FIFO。


非公平性锁可能使线程“饥饿”,当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。


非公平锁可能使线程“饥饿”,为什么它又被设定成默认的实现呢?非公平性锁模式下线程上下文切换的次数少,因此其性能开销更小。公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。


读写锁

在Java并发包中常用的锁(如ReentrantLock),基本上都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。


除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的数据结构用作缓存,它大部分时间提供读服务(例如:查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。


在没有读写锁支持的(Java 5 之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键字进行同步),这样做的目的是使读操作都能读取到正确的数据,而不会出现脏读。


改用读写锁实现上述功能,只需要在读操作时获取读锁,而写操作时获取写锁即可,当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。


一般情况下,读写锁的性能都会比排它锁要好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock。


ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

Lock r = rwl.readLock();

Lock w = rwl.writeLock();

Condition接口

任何一个Java对象,都拥有一组监视器方法,主要包括wait()、notify()、notifyAll()方法,这些方法与synchronized关键字配合使用可以实现等待/通知模式。Condition接口也提供类似的Object的监视器的方法,主要包括await()、signal()、signalAll()方法,这些方法与Lock锁配合使用也可以实现等待/通知模式。

相比Object实现的监视器方法,Condition接口的监视器方法具有一些Object所没有的特性:

Condition接口可以支持多个等待队列:一个Lock实例可以绑定多个Condition。

Condition接口支持在等待时不响应中断:wait()是会响应中断的;

Condition接口支持等待到将来的某个时间点返回(和awaitNanos(long)/wait(long)不同!):awaitUntil(Date deadline);

class BoundedBuffer {
    final Lock lock = new ReentrantLock();// 锁对象
    final Condition notFull = lock.newCondition(); //写线程条件
    final Condition notEmpty = lock.newCondition();//读线程条件
    final Object[] items = new Object[100];// 初始化一个长度为100的队列
    int putptr/* 写索引 */, takeptr/* 读索引 */, count/* 队列中存在的数据个数 */;
    public void put(Object x) throws InterruptedException {
        lock.lock(); //获取锁
        try {
            while (count == items.length)
                notFull.await();// 当计数器count等于队列的长度时,不能再插入,因此等待。阻塞写线程。
            items[putptr] = x;//赋值
            putptr++;
            if (putptr == items.length)
                putptr = 0;// 若写索引写到队列的最后一个位置了,将putptr置为0。
            count++; // 每放入一个对象就将计数器加1。
            notEmpty.signal(); // 一旦插入就唤醒取数据线程。
        } finally {
            lock.unlock(); // 最后释放锁
        }
    }
    public Object take() throws InterruptedException {
        lock.lock(); // 获取锁
        try {
            while (count == 0)
                notEmpty.await(); // 如果计数器等于0则等待,即阻塞读线程。
            Object x = items[takeptr]; // 取值
            takeptr++;
            if (takeptr == items.length)
                takeptr = 0; //若读锁应读到了队列的最后一个位置了,则读锁应置为0;即当takeptr达到队列长度时,从零开始取
            count++; // 每取一个将计数器减1。
            notFull.signal(); //枚取走一个就唤醒存线程。
            return x;
        } finally {
            lock.unlock();// 释放锁
        }
    }
}

 

Java里的阻塞队列

ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。

LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。

PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。

DelayQueue:一个使用优先级队列实现的无界阻塞队列;支持延时获取元素——在创建元素时可以指定多久才能从队列中取出当前元素;

SynchronousQueue:一个不存储元素的阻塞队列——每一个put操作必须等待一个take操作;

LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

// 大小1000的、线程公平的阻塞队列;

// 传入了大小参数,这就叫有界;

ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000, true);

阻塞队列的实现原理,见前面BoundedBuffer的代码。(一个队列,一个锁,两个Condition:notFull,notEmpty,等待通知模型)

 

参考:https://www.jianshu.com/p/8d90dc5b341e

▲ 24 简述 JVM 的内存模型 JVM 内存是如何对应到操作系统内存的?


▲ 20 集合类中的 List 和 Map 的线程安全版本是什么,如何保证线程安全的?


▲ 15 String 类能不能被继承?为什么?

我们都知道String类不能被继承,但为什么不能却不能回答地很完整。

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** Cache the hash code for the string */
    private int hash; // Default to 0
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
    
     public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

从String类的源码我们可以看出,该类是被final关键字修饰的,并且String类实际是一个被final关键字修饰的char[]数组,所以实现细节上也是不允许改变,这就是String类的Immutable(不可变)属性。

为什么说String类是被final关键字修饰的所以不能被继承?

在《Java编程思想》中对于final关键字有这样的介绍:

根据程序上下文环境,Java关键字final有“这是无法改变的”或者“终态的”含义,它可以修饰非抽象类、非抽象类成员方法和变量。你可能出于两种理解而需要阻止改变:设计或效率。

1、final类不能被继承,没有子类,final类中的方法默认是final的。   final方法不能被子类的方法覆盖,但可以被继承。

2、final成员变量表示常量,只能被赋值一次,赋值后值不再改变。   final不能用于修饰构造方法。

注意:父类的private成员方法是不能被子类方法覆盖的,因此private类型的方法默认是final类型的。

如果一个类不允许其子类覆盖某个方法,则可以把这个方法声明为final方法。

通过上述介绍,可以发现final关键字可以用来修饰类、方法和变量(包括成员变量和局部变量)。

1、当final关键字修饰类时

当一个类被final修饰时,表明这个类不能被继承。被final关键字修饰的类中的成员变量可以根据需要设为final,但是要注意final类中的所有成员方法都会被隐式地指定为final方法。

当我们在开发过程中如果让一个类继承一个被final关键字修饰的类时,编辑器也会报错,“Demo1不能成为最终类FinalClass的子类,请移除FinalClass的final关键字”。


2、当final关键字修饰方法时

当一个方法被final关键字修饰时,则父类的该方法不能被子类所覆盖。《Java编程思想》中提到:

使用final方法的原因有两个。

第一个原因是把方法锁定,以防任何继承类修改它的含义;

第二个原因是效率。在早期的Java实现版本中,会将final方法转为内嵌调用。


3、当final关键字修饰变量(包括成员变量和局部变量)时

当final关键字修饰变量时,表示该变量是常量,在初始化时便要赋值并且只能被赋值一次,初始化之后不能更改。。如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象,但是它指向的对象的内容是可变的。

当用final作用于类的成员变量时,成员变量必须在声明时或者构造器中进行初始化赋值,否则会报错,而局部变量只需要在使用之前被初始化赋值即可:

在这里插入图片描述

对于final修饰的变量初始化之后不能更改,如果更改也会报错。

其实在问String类能否被继承时,不仅仅考察的是对于String源码的理解,还有对于final关键字的掌握。

参考资料:  《Java编程思想》

▲ 14 Java 线程和操作系统的线程是怎么对应的?Java线程是怎样进行调度的?


▲ 11 简述 BIO, NIO, AIO 的区别

在计算机系统中I/O就是输入(Input)和输出(Output)的意思,针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O, Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。


一个系统的优化空间,往往都在低效率的I/O环节上,很少看到一个系统CPU、内存的性能是其整个系统的瓶颈。也正因为如此,Java在I/O上也一直在做持续的优化,从JDK 1.4开始便引入了NIO模型,大大的提高了以往BIO模型下的操作效率。

一、同步阻塞I/O(BIO):

同步阻塞I/O,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制来改善。BIO方式适用于连接数目比较小且固定的架构,这种方式对服务端资源要求比较高,并发局限于应用中,在jdk1.4以前是唯一的io现在,但程序直观简单易理解;


二、同步非阻塞I/O(NIO):

同步非阻塞I/O,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,jdk1,4开始支持

NIO(reactor模型):线程发起IO请求,立即返回;内核在做好IO操作的准备之后,通过调用注册的回调函数通知线程做IO操作,线程开始阻塞,直到操作完成。如下图:

相对于BIO的流,NIO抽象出了新的通道(Channel)作为输入输出的通道,并且提供了缓存(Buffer)的支持,在进行读操作时,需要使用Buffer分配空间,然后将数据从Channel中读入Buffer中,对于Channel的写操作,也需要现将数据写入Buffer,然后将Buffer写入Channel中。


通过比较New IO的使用方式我们可以发现,新的IO操作不再面向 Stream来进行操作了,改为了通道Channel,并且使用了更加灵活的缓存区类Buffer,Buffer只是缓存区定义接口, 根据需要,我们可以选择对应类型的缓存区实现类。在java NIO编程中,我们需要理解以下3个对象Channel、Buffer和Selector。


Channel

首先说一下Channel,国内大多翻译成“通道”。Channel和IO中的Stream(流)是差不多一个等级的。只不过Stream是单向的,譬如:InputStream, OutputStream。而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作,NIO中的Channel的主要实现有:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel;通过看名字就可以猜出个所以然来:分别可以对应文件IO、UDP和TCP(Server和Client)。


Buffer

NIO中的关键Buffer实现有:ByteBuffer、CharBuffer、DoubleBuffer、 FloatBuffer、IntBuffer、 LongBuffer,、ShortBuffer,分别对应基本数据类型: byte、char、double、 float、int、 long、 short。当然NIO中还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等这里先不具体陈述其用法细节。


Selector

Selector 是NIO相对于BIO实现多路复用的基础,Selector 运行单线程处理多个 Channel,如果你的应用打开了多个通道,但每个连接的流量都很低,使用 Selector 就会很方便。例如在一个聊天服务器中。要使用 Selector , 得向 Selector 注册 Channel,然后调用它的 select() 方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新的连接进来、数据接收等。


三、异步非阻塞I/O(AIO):

异步非阻塞I/O,服务器实现模式为一个有效请求一个线程,客户端的IO请求都是由操作系统先完成了再通知服务器用其启动线程进行处理。AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,jdk1.7开始支持。


AIO(proactor模型):线程发起IO请求,立即返回;内存做好IO操作的准备之后,做IO操作,直到操作完成或者失败,通过调用注册的回调函数通知线程做IO操作完成或者失败。



四、IO与NIO区别:

IO面向流,NIO面向缓冲区

IO的各种流是阻塞的,NIO是非阻塞模式

Java NIO的选择允许一个单独的线程来监视多个输入通道,可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入或选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道;


五、同步与异步的区别:

同步:发送一个请求,等待返回,再发送下一个请求,同步可以避免出现死锁,脏读的发生

异步:发送一个请求,不等待返回,随时可以再发送下一个请求,可以提高效率,保证并发

同步异步关注点在于消息通信机制,

阻塞与非阻塞关注的是程序在等待调用结果时(消息、返回值)的状态:

阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。

非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程


不同层次:

CPU层次:操作系统进行IO或任务调度层次,现代操作系统通常使用异步非阻塞方式进行IO(有少部分IO可能会使用同步非阻塞),即发出IO请求后,并不等待IO操作完成,而是继续执行接下来的指令(非阻塞),IO操作和CPU指令互不干扰(异步),最后通过中断的方式通知IO操作的完成结果。

线程层次:操作系统调度单元的层次,操作系统为了减轻程序员的思考负担,将底层的异步非阻塞的IO方式进行封装,把相关系统调用(如read和write)以同步的方式展现出来,然而同步阻塞IO会使线程挂起,同步非阻塞IO会消耗CPU资源在轮询上,3个解决方法;

多线程(同步阻塞)

IO多路复用(select、poll、epoll)

直接暴露出异步的IO接口,kernel-aio和IOCP(异步非阻塞)


Linux IO模型:

阻塞/非阻塞:等待I/O完成的方式,阻塞要求用户程序停止执行,直到IO完成,而非阻塞在IO完成之前还可以继续执行

同步/异步:获知IO完成的方式,同步需要时刻关心IO是否完成,异步无需主动关心,在IO完成时它会收到通知


总 结

IO实质上与线程没有太多的关系,但是不同的IO模型改变了应用程序使用线程的方式,NIO与AIO的出>现解决了很多BIO无法解决的并发问题,当然任何技术抛开适用场景都是耍流氓,复杂的技术往往是为了解决简单技术无法解决的问题而设计的,在系统开发中能用常规技术解决的问题,绝不用复杂技术,>否则大大增加系统代码的维护难度,学习IT技术不是为了炫技,而是要实实在在解决问题。

BIO是一个连接一个线程。

NIO是一个请求一个线程。

AIO是一个有效请求一个线程。


▲ 11 实现单例设计模式(懒汉,饿汉)

结合Java的hanpen before原则一起看。

▲ 8 == 和 equals() 的区别?

==:

== 比较的是变量(栈)内存中存放的对象的(堆)内存地址,用来判断两个对象的地址是否相同,即是否是指相同一个对象。比较的是真正意义上的指针操作。 

1、比较的是操作符两端的操作数是否是同一个对象。

2、两边的操作数必须是同一类型的(可以是父子类之间)才能编译通过。

3、比较的是地址,如果是具体的阿拉伯数字的比较,值相等则为true,如:

int a=10 与 long b=10L 与 double c=10.0都是相同的(为true),因为他们都指向地址为10的堆。

equals:

equals用来比较的是两个对象的内容是否相等,由于所有的类都是继承自java.lang.Object类的,所以适用于所有对象,如果没有对该方法进行覆盖的话,调用的仍然是Object类中的方法,而Object中的equals方法返回的却是==的判断。

String s="abce"是一种非常特殊的形式,和new 有本质的区别。它是java中唯一不需要new 就可以产生对象的途径。以String s="abce";形式赋值在java中叫直接量,它是在常量池中而不是象new一样放在压缩堆中。这种形式的字符串,在JVM内部发生字符串拘留,即当声明这样的一个字符串后,JVM会在常量池中先查找有有没有一个值为"abcd"的对象,如果有,就会把它赋给当前引用.即原来那个引用和现在这个引用指点向了同一对象,如果没有,则在常量池中新创建一个"abcd",下一次如果有String s1 = "abcd";又会将s1指向"abcd"这个对象,即以这形式声明的字符串,只要值相等,任何多个引用都指向同一对象.

而String s = new String("abcd");和其它任何对象一样.每调用一次就产生一个对象,只要它们调用。

▲ 8 简述 Spring AOP 的原理


▲ 6 简述 synchronized,volatile,可重入锁的不同使用场景及优缺点

▲ 2 简述 Java 的 happen before 原则

什么是happen-before

JVM内存模型:

java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。java中的共享变量是存储在内存中的,多个线程由其工作内存,其工作方式是将共享内存中的变量拿出来放在工作内存,操作完成后,再将最新的变量放回共享变量,这时其他的线程就可以获取到最新的共享变量。

示意图:

    线程A                     线程B

本地内存A              本地内存B

共享变量的副本    共享变量的副本

              JVM控制

                 主内存  

共享变量1  共享变量2   共享变量3



从横向去看看,线程A和线程B就好像通过共享变量在进行隐式通信。这其中有很有意思的问题,如果线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就出现了 “脏读” 现象。

为避免脏读,可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的。

说明:本地内存是JVM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。(不完全是内存,也不完全是Cache)


重排序:

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

1、编译器优化重排序

2、指令级并行重排序

3、内存系统重排序

4、最终执行的指令序列


这些重排序会导致线程安全的问题,一个很经典的例子就是double-check-locking (DCL)的问题。JVM的编译器重排序规则会禁止一些特定类型的编译器重排序;针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。

(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

(2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

(3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。


JVM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JVM向程序员保证a操作将对b操作可见)。


具体的定义为:

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JVM允许这种重排序)。


具体的规则:

(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

(6)Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

(7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。


顺序一致性内存模型:顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

1、一个线程中的所有操作必须按照程序的顺序来执行。

2、(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。


JVM保证:单线程程序和正确同步的多线程程序的执行结果与在顺序一致性内存模型中的执行结果相同。


volatile的内存语义(对内存可见性的影响):

当写一个volatile变量时,JVM会把该线程对应的本地内存中的共享变量刷新到主内存。

当读一个volatile变量时,JVM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

在这里插入代码描述:

double pi=3..14; //A

double r = 1.0; //B

double area = pi*r*r; //C

利用程序顺序规则(规则1)存在三个happens-before关系:

A happens-before B;

B happens-before C;

A happens-before C。

这里的第三个关系是利用传递性进行推论的。这里的第三个关系是利用传递性进行推论的。

A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序。

对一个volatile变量的写操作happen—before后面(时间上)对该变量的读操作。

参考:https://blog.csdn.net/ma_chen_qq/article/details/82990603


通过单例模式你可以: 

一、确保一个类只有一个实例被建立 

二、提供了一个对对象的全局访问指针 

三、在不影响单例类的客户端的情况下允许将来有多个实例

经典的单例模式有三种,懒汉式、饿汉式和 登记式。

懒汉式的特点是延迟加载,比如配置文件,采用懒汉式的方法,顾名思义,懒汉么,很懒的,配置文件的实例直到用到的时候才会加载。。。。。。

饿汉式的特点是一开始就加载了,如果说懒汉式是“时间换空间”,那么饿汉式就是“空间换时间”,因为一开始就创建了实例,所以每次用到的之后直接返回就好了。


懒汉模式:


方案一:

利用类第一次使用才加载,加载时同步的特性。

优点是:官方推荐,可以可以保证实现懒汉模式。代码少。

缺点是:第一次加载比较慢,而且多了一个类多了一个文件。

public class SingletonKerriganF {     
      
    private static class SingletonHolder {     
        static final SingletonKerriganF INSTANCE = new SingletonKerriganF();     
    }     
      
    public static SingletonKerriganF getInstance() {     
        return SingletonHolder.INSTANCE;     
    }     
}     
volatile禁止了指令重排序:
优点:保持了DCL,比较简单
确定:volatile这个关键字多少会带来一些性能影响吧。
public class Singleton(){  
    private volatile static Singleton singleton;  
    private Sington(){};  
    public static Singleton getInstance(){  
        if(singleton == null){  
            synchronized (Singleton.class){  
                if(singleton == null){  
                     singleton = new Singleton();    
                }  
            }
        }           
        return singleton;  
    }  
}


初始化完后赋值。

通过一个temp,来确定初始化结束后其他线程才能获得引用。

同时注意,JIT可能对这一部分优化,我们必须阻止JTL这部分的"优化"。

//饿汉式:

缺点是有点难理解,优点是:可以不用volatile关键字,又可以用DLC

public class Singleton {    
    
    private static Singleton singleton; // 这类没有volatile关键字    
    
    private Singleton() {    
    }    
    
    public static Singleton getInstance() {    
        // 双重检查加锁    
        if (singleton == null) {    
            synchronized (Singleton.class) {    
                // 延迟实例化,需要时才创建    
                if (singleton == null) {    
                        
                    Singleton temp = null;  
                    try {  
                        temp = new Singleton();    
                    } catch (Exception e) {  
                    }  
                    if (temp != null){
                    //为什么要做这个看似无用的操作,因为这一步是为了让虚拟机执行到这一步的时会才对singleton赋值,虚拟机执行到这里的时候,必然已经完成类实例的初始化。所以这种写法的DCL是安全的。由于try的存在,虚拟机无法优化temp是否为null  
                        singleton = temp;
                    }     
                }    
            }    
        }    
        return singleton;    
    }  
}


登记式实际对一组单例模式进行的维护,主要是在数量上的扩展,通过map我们把单例存进去,这样在调用时,先判断该单例是否已经创建,是的话直接返回,不是的话创建一个登记到map中,再返回。对于数量又分为固定数量和不固定数量的。下面采用的是不固定数量的方式,在getInstance方法中加上参数(string name)。然后通过子类继承,重写这个方法将name传进去。让我们看看代码吧。

//采用Map配置多个单例
public class MySingleton3 {
	
	// 设立静态变量,直接创建实例
	private static Map<String, MySingleton3> map = new HashMap<String, MySingleton3>();
		
	// -----受保护的-----构造函数,不能是私有的,但是这样子类可以直接访问构造方法了
	//解决方式是把你的单例类放到一个外在的包中,以便在其它包中的类(包括缺省的包)无法实例化一个单例类。
	protected MySingleton3() {
		System.out.println("-->私有化构造函数被调用,创建实例中");
		
	}
 
	// 开放一个公有方法,判断是否已经存在实例,有返回,没有新建一个在返回
	public static MySingleton3 getInstance(String name) {
		if (name == null) {
			name = MySingleton3.class.getName();
			System.out.println("-->name不存在,name赋值等于"+MySingleton3.class.getName());
		}
				
		//map的线程安全需要保证,name可以使用hash值替换
		if (map.get(name) == null) {
			try {
				System.out.println("-->name对应的值不存在,开始创建");
				map.put(name, (MySingleton3)Class.forName(name).newInstance());
			} catch (InstantiationException e) {
				e.printStackTrace();
			} catch (IllegalAccessException e) {
				e.printStackTrace();
			} catch (ClassNotFoundException e) {
				e.printStackTrace();
			}
		}else {
			System.out.println("-->name对应的值存在");
		}
		System.out.println("-->返回name对应的值");
		return map.get(name);
	}
	
	public Map<String, MySingleton3> getMap(){
		return map;
	}
}
public class MySingleton3Childa extends MySingleton3 {
	
	public static MySingleton3Childa getInstance() {
		return (MySingleton3Childa) MySingleton3Childa
				.getInstance("com.xq.mysingleton.MySingleton3Childa");
	}
 
	//随便写一个测试的方法
	public String about() {
		return "---->我是MySingleton3的第一个子类MySingleton3Childa";
	}
}
public class MySingleton3Childb extends MySingleton3 {
	
	static public MySingleton3Childb getInstance() {
		return (MySingleton3Childb) MySingleton3Childb
				.getInstance("com.xq.mysingleton.MySingleton3Childb");
	}
 
	//随便写一个测试的方法
	public String about() {
		return "---->我是MySingleton3的第二个子类MySingleton3Childb";
	}
}

参考:https://blog.csdn.net/lanzhizhuxia/article/details/7924373

以下内容来源自清华大学出版社的《研磨设计模式》。


先简单看看类级内部类相关的知识

*什么是类级内部类?

简单点说,类级内部类指的是,有static修饰的成员式内部类。如果没有static修饰的成员式内部类叫对象级内部类。

*类级内部类相当于其外部类的static成分,他的对象与外部类对象间不存在依赖关系,因此可直接创建,而对象级内部类的实例,是绑定在外部对象实例中的。

*类级内部类中,可以定义静态的方法。在静态的方法中只能够引用外部类的中的静态成员方法或者成员变量。

*类级内部类相当于其外部类的成员,只有在第一次被使用的时候才会被装载。

再来看看多线程缺省同步锁的知识。

       

大家都知道,在多线程开发中,为了解决并发问题,主要通过使用synchronized来加互斥锁进行同步控制。但是在某些情况中,JVM已经隐含的执行了同步,这些情况下就不用自己再来进行同步控制了,这些情况包括:

*由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时

*访问 final 字段时

*再创建线程之前创建对象时

*线程可以看见他将要处理的对象时


由此想要很简单的实现线程安全,可以采用静态初始化器的方式,他可以由JVM来保证线程的安全性。比如第一节的饿汉式实现方式。但是这样一来,会浪费一定的空间,因为这种实现方式,会在类装载的时候就初始化对象,不管你需不需要。

如果现在有一种方法能够让类装载的时候不会初始化对象,不就解决问题了?一种可行的方式就是采用类级内部类,在这个类级内部类里面去创建对象实例。这样一来,只要不使用这个类级内部类,那就不会创建对象实例。从而同时实现延迟加载和线程安全。

/**
 * 懒汉式单例模式改进
 * 实现延迟加载,缓存
 * Lazy initialization holder class
 * 这个模式综合运用了java的类级内部类和多线程缺省同步锁的知识
 * @author qian.xu
 *
 */
public class MySingleton2a {
/**
* 类级的内部类,也就是静态的成员式内部类,该内部类的实例与外部类的实例
* 没有绑定的关系,而且只有被调用到才会装载,从而实现了延迟加载
* @author qian.xu
*
*/
private static class Singleton{
/**
* 静态初始化器,用JVM来保证线程安全
*/
private static MySingleton2a singleton = new MySingleton2a();
static {
System.out.println("---->类级的内部类被加载");
}
private Singleton(){
System.out.println("---->类级的内部类构造函数被调用");
}
}
//私有化构造函数
private MySingleton2a(){
System.out.println("-->开始调用构造函数");
}
//开放一个公有方法,判断是否已经存在实例,有返回,没有新建一个在返回
public static MySingleton2a getInstance(){
System.out.println("-->开始调用公有方法返回实例");
MySingleton2a s1 = null;
s1 = Singleton.singleton;
System.out.println("-->返回单例");
return s1;
}
}
/**
 * 懒汉式单例模式改进
 * 实现了延迟加载
 * MySingleton2
 */
public static void myprint2a(){
System.out.println("---------------懒汉式单例模式改进--------------");
System.out.println("第一次取得实例(改进懒汉式)");
MySingleton2a s1 = MySingleton2a.getInstance();
System.out.println("第二次取得实例(改进懒汉式)");
MySingleton2a s2 = MySingleton2a.getInstance();
if(s1==s2){
System.out.println(">>>>>s1,s2为同一实例(改进懒汉式)<<<<<");
}
System.out.println();
}

▲ 1 SpringBoot 是如何进行自动配置的?


本文为呱牛笔记原创文章,转载无需和我联系,但请注明来自呱牛笔记 ,it3q.com

请先登录后发表评论
  • 最新评论
  • 总共0条评论