1. Map相关问题
问题一:介绍一下HashMapjava
HashMap 是一个散列桶(数组和链表),它存储的内容是键值对 key-value 映射 HashMap 采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改 HashMap 是非 synchronized,因此 HashMap 很快 HashMap 能够接受 null 键和值,而 Hashtable 则不能(缘由就是 equlas() 方法须要对象, 由于 HashMap 是后出的 API 通过处理才能够)
问题二:HashMap 的工做原理是什么?node
HashMap 是基于 hashing 的原理 咱们使用 put(key, value) 存储对象到 HashMap 中,使用 get(key) 从 HashMap 中获取对象。当咱们给 put() 方法传递键和值时, 咱们先对键调用 hashCode() 方法,计算并返回的 hashCode 是用于找到 Map 数组的 bucket 位置来储存 Node 对象。 这里关键点在于指出,HashMap 是在 bucket 中储存键对象和值对象,做为Map.Node 。
如下是 HashMap 初始化 简化的模拟数据结构: Node[] table = new Node[16]; // 散列桶初始化,table class Node { hash; //hash值 key; //键 value; //值 node next; //用于指向链表的下一层(产生冲突,用拉链法) } 如下是具体的 put 过程(JDK1.8) 对 Key 求 Hash 值,而后再计算下标 若是没有碰撞,直接放入桶中(碰撞的意思是计算获得的 Hash 值相同,须要放到同一个 bucket 中) 若是碰撞了,以链表的方式连接到后面 若是链表长度超过阀值(TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表 若是节点已经存在就替换旧值 若是桶满了(容量16 * 加载因子0.75),就须要 resize(扩容2倍后重排) 如下是具体 get 过程 考虑特殊状况:若是两个键的 hashcode 相同,你如何获取值对象? 当咱们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,找到 bucket 位置以后, 会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
问题三:有什么方法能够减小碰撞?程序员
扰动函数能够减小碰撞 原理是若是两个不相等的对象返回不一样的 hashcode 的话,那么碰撞的概率就会小些。这就意味着存链表结构减少, 这样取值的话就不会频繁调用 equal 方法,从而提升 HashMap 的性能(扰动即 Hash 方法内部的算法实现, 目的是让不一样对象返回不一样 hashcode)。 使用不可变的、声明做 final 对象,而且采用合适的 equals() 和 hashCode() 方法,将会减小碰撞的发生 不可变性使得可以缓存不一样键的 hashcode,这将提升整个获取对象的速度, 使用 String、Integer 这样的 wrapper 类做为键是很是好的选择。
问题四:为何 String、Integer 这样的 wrapper 类适合做为键?web
由于 String 是 final,并且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,由于为了要计算 hashCode(), 就要防止键值改变,若是键值在放入时和获取时返回不一样的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。
问题五:HashMap 中 hash 函数怎么是实现的?算法
咱们能够看到,在 hashmap 中要找到某个元素,须要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。 前面说过,hashmap 的数据结构是数组和链表的结合,因此咱们固然但愿这个 hashmap 里面的元素位置尽可能的分布均匀些, 尽可能使得每一个位置上的元素数量只有一个。那么当咱们用 hash 算法求得这个位置的时候,立刻就能够知道对应位置的元素就是咱们要的, 而不用再去遍历链表。 因此,咱们首先想到的就是把 hashcode 对数组长度取模运算。这样一来,元素的分布相对来讲是比较均匀的。 可是“模”运算的消耗仍是比较大的,能不能找一种更快速、消耗更小的方式?咱们来看看 JDK1.8 源码是怎么作的(被楼主修饰了一下) static final int hash(Object key) { if (key == null){ return 0; } int h; h = key.hashCode();返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 //其中n是数组的长度,即Map的数组部分初始化长度 return (n-1)&(h ^ (h >>> 16)); }
简单来讲就是: 高16 bit 不变,低16 bit 和高16 bit 作了一个异或(获得的 hashcode 转化为32位二进制,前16位和后16位低16 bit 和高16 bit 作了一个异或) (n·1) & hash = -> 获得下标
问题六:拉链法致使的链表过深,为何不用二叉查找树代替而选择红黑树?为何不一直使用红黑树?数组
之因此选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊状况下会变成一条线性结构(这就跟原来使用链表结构同样了, 形成层次很深的问题),遍历查找会很是慢。而红黑树在插入新数据后可能须要经过左旋、右旋、变色这些操做来保持平衡。 引入红黑树就是为了查找数据快,解决链表查询深度的问题。咱们知道红黑树属于平衡二叉树,为了保持“平衡”是须要付出代价的, 可是该代价所损耗的资源要比遍历线性链表要少。因此当长度大于8的时候,会使用红黑树;若是链表长度很短的话, 根本不须要引入红黑树,引入反而会慢。
问题七:说说你对红黑树的看法?
缓存
每一个节点非红即黑 根节点老是黑色的 若是节点是红色的,则它的子节点必须是黑色的(反之不必定) 每一个叶子节点都是黑色的空节点(NIL节点) 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
问题八:解决 hash 碰撞还有那些办法?安全
当冲突发生时,使用某种探查技术在散列表中造成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。 按照造成探查序列的方法不一样,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。 下面给一个线性探查法的例子: 问题:已知一组关键字为 (26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数, 用线性探查法解决冲突构造这组关键字的散列表。 解答:为了减小冲突,一般令装填因子 α 由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为 (0,10,2,12,5,2,3, 12,6,12)。 前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入 T[0]、T[10)、T[2]、T[12] 和 T[5] 中。 当插入第6个关键字15时,其散列地址2(即 h(15)=15%13=2)已被关键字 41(15和41互为同义词)占用。 故探查 h1=(2+1)%13=3,此地址开放,因此将 15 放入 T[3] 中。 当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。 当插入第8个关键字12时,散列地址12已被同义词38占用,故探查 hl=(12+1)%13=0,而 T[0] 亦被26占用, 再探查 h2=(12+2)%13=1,此地址开放,可将12插入其中。 相似地,第9个关键字06直接插入 T[6] 中;而最后一个关键字51插人时,因探查的地址 12,0,1,…,6 均非空, 故51插入 T[7] 中。
问题九:若是 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办?服务器
HashMap 默认的负载因子大小为0.75。也就是说,当一个 Map 填满了75%的 bucket 时候,和其它集合类同样(如 ArrayList 等), 将会建立原来 HashMap 大小的两倍的 bucket 数组来从新调整 Map 大小,并将原来的对象放入新的 bucket 数组中。 这个过程叫做 rehashing。 由于它调用 hash 方法找到新的 bucket 位置。这个值只可能在两个地方,一个是原下标的位置, 另外一种是在下标为 <原下标+原容量> 的位置。
问题十:从新调整 HashMap 大小存在什么问题吗?数据结构
从新调整 HashMap 大小的时候,确实存在条件竞争。 由于若是两个线程都发现 HashMap 须要从新调整大小了,它们会同时试着调整大小。在调整大小的过程当中, 存储在链表中的元素的次序会反过来。由于移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部。 这是为了不尾部遍历(tail traversing)。若是条件竞争发生了,那么就死循环了。多线程的环境下不使用 HashMap。 为何多线程会致使死循环,它是怎么发生的? HashMap 的容量是有限的。当通过屡次元素插入,使得 HashMap 达到必定饱和度时,Key 映射位置发生冲突的概率会逐渐提升。 这时候, HashMap 须要扩展它的长度,也就是进行Resize。 扩容:建立一个新的 Entry 空数组,长度是原数组的2倍 rehash:遍历原 Entry 数组,把全部的 Entry 从新 Hash 到新数组
问题十一:HashTable
数组 + 链表方式存储 默认容量:11(质数为宜) put操做:首先进行索引计算 (key.hashCode() & 0x7FFFFFFF)% table.length;若在链表中找到了,则替换旧值,若未找到则继续; 当总元素个数超过 容量 * 加载因子 时,扩容为原来 2 倍并从新散列;将新元素加到链表头部 对修改 Hashtable 内部共享数据的方法添加了 synchronized,保证线程安全
问题十一:HashMap 与 HashTable 区别
默认容量不一样,扩容不一样 线程安全性:HashTable 安全 效率不一样:HashTable 要慢,由于加锁
问题十二:可使用 CocurrentHashMap 来代替 Hashtable 吗?
咱们知道 Hashtable 是 synchronized 的,可是 ConcurrentHashMap 同步性能更好,由于它仅仅根据同步级别对 map 的一部分进行上锁 ConcurrentHashMap 固然能够代替 HashTable,可是 HashTable 提供更强的线程安全性 它们均可以用于多线程的环境,可是当 Hashtable 的大小增长到必定的时候,性能会急剧降低,由于迭代时须要被锁定很长的时间。 因为 ConcurrentHashMap 引入了分割(segmentation),不论它变得多么大,仅仅须要锁定 Map 的某个部分, 其它的线程不须要等到迭代完成才能访问 Map。简而言之,在迭代的过程当中,ConcurrentHashMap 仅仅锁定 Map 的某个部分, 而 Hashtable 则会锁定整个 Map
问题十三:CocurrentHashMap(JDK 1.7)
CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成 Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每一个 HashEntry 一个链表结构的元素, 利用 Hash 算法获得索引肯定归属的数据段,也就是对应到在修改时须要竞争获取的锁。 ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时, 不会影响到其余的 Segment 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性 首先是经过 key 定位到 Segment,以后在对应的 Segment 中进行具体的 put 操做以下: 将当前 Segment 中的 table 经过 key 的 hashcode 定位到 HashEntry。 遍历该 HashEntry,若是不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value 不为空则须要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否须要扩容 最后会解除在 1 中所获取当前 Segment 的锁 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,可是并不能保证并发的原子性,因此 put 操做时仍然须要加锁处理 首先第一步的时候会尝试获取锁,若是获取失败确定就有其余线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁 尝试自旋获取锁 若是重试的次数达到了 MAX_SCAN_RETRIES 则改成阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁
问题十四:CocurrentHashMap(JDK 1.8)
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。 其中的 val next 都用了 volatile 修饰,保证了可见性。 最大特色是引入了 CAS 借助 Unsafe 来实现 native code。CAS有3个操做数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时, 将内存值V修改成 B,不然什么都不作。Unsafe 借助 CPU 指令 cmpxchg 来实现。 CAS 使用实例 对 sizeCtl 的控制都是用 CAS 来实现的: -1 表明 table 正在初始化 N 表示有 -N-1 个线程正在进行扩容操做 若是 table 未初始化,表示table须要初始化的大小 若是 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2)) CAS 会出现的问题:ABA 解决:对变量增长一个版本号,每次修改,版本号加 1,比较的时候比较版本号。 put 过程 根据 key 计算出 hashcode 判断是否须要进行初始化 经过 key 定位出的 Node,若是为空表示当前位置能够写入数据,利用 CAS 尝试写入,失败则自旋保证成功 若是当前位置的 hashcode == MOVED == -1,则须要进行扩容 若是都不知足,则利用 synchronized 锁写入数据 若是数量大于 TREEIFY_THRESHOLD 则要转换为红黑树 get 过程 根据计算出来的 hashcode 寻址,若是就在桶上那么直接返回值 若是是红黑树那就按照树的方式获取值 就不知足那就按照链表的方式遍历获取值 注: ConcurrentHashMap 在 Java 8 中存在一个 bug 会进入死循环,缘由是递归建立 ConcurrentHashMap 对象, 可是在 JDK 1.9 已经修复了。场景重现以下: public class ConcurrentHashMapDemo{ private Map<Integer,Integer> cache =new ConcurrentHashMap<>(15); public static void main(String[]args){ ConcurrentHashMapDemo ch = new ConcurrentHashMapDemo(); System.out.println(ch.fibonaacci(80)); } public int fibonaacci(Integer i){ if(i==0||i ==1) { return i; } return cache.computeIfAbsent(i,(key) -> { System.out.println("fibonaacci : "+key); return fibonaacci(key -1)+fibonaacci(key - 2); }); } }
1. Synchronized 相关问题
问题一:Synchronized 用过吗,其原理是什么?问题一:Synchronized 用过吗,其原理是什么?
Synchronized 是由 JVM 实现的一种实现互斥同步的一种方式,若是你查看被 Synchronized 修饰过的程序块编译后的字节码, 会发现,被 Synchronized 修饰过的程序块,在编译先后被编译器生成了 monitorenter 和 monitorexit 两个字节码指令。 这两个指令是什么意思呢? 在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁: 若是这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器 +1;当执行 monitorexit 指令时将锁计数器 -1; 当计数器为 0 时,锁就被释放了。 若是获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。 Java 中 Synchronize 经过在对象头设置标记,达到了获取锁和释放锁的目的。
问题二:你刚才提到获取对象的锁,这个“锁”究竟是什么?如何肯定对象的锁?问题二:你刚才提到获取对象的锁,这个“锁”究竟是什么?如何肯定对象的锁?
“锁”的本质实际上是 monitorenter 和 monitorexit 字节码指令的一个 Reference 类型的参数,即要锁定和解锁的对象。 咱们知道,使用 Synchronized 能够修饰不一样的对象,所以,对应的对象锁能够这么肯定。 1.若是 Synchronized 明确指定了锁对象,好比 Synchronized(变量名)、Synchronized(this) 等,说明加解锁对象为该对象。 2.若是没有明确指定: 若 Synchronized 修饰的方法为非静态方法,表示此方法对应的对象为锁对象; 若 Synchronized 修饰的方法为静态方法,则表示此方法对应的类对象为锁对象。 注意,当一个对象被锁住时,对象里面全部用 Synchronized 修饰的方法都将产生堵塞, 而对象里非 Synchronized 修饰的方法可正常被调用,不受锁影响。
问题三:什么是可重入性,为何说 Synchronized 是可重入锁?
可重入性是锁的一个基本要求,是为了解决本身锁死本身的状况。 对 Synchronized 来讲,可重入性是显而易见的,刚才提到,在执行 monitorenter 指令时,若是这个对象没有锁定, 或者当前线程已经拥有了这个对象的锁(而不是已拥有了锁则不能继续获取),就把锁的计数器 +1, 其实本质上就经过这种方式实现了可重入性。
问题四:JVM 对 Java 的原生锁作了哪些优化?
在 Java 6 以前,Monitor 的实现彻底依赖底层操做系统的互斥锁来实现,也就是咱们刚才在问题二中所阐述的获取/释放锁的逻辑。 因为 Java 层面的线程与操做系统的原生线程有映射关系,若是要将一个线程进行阻塞或唤起都须要操做系统的协助, 这就须要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代 JDK 中作了大量的优化。 一种优化是使用自旋锁,即在把线程进行阻塞操做以前先让线程自旋等待一段时间,可能在等待期间其余线程已经解锁, 这时就无需再让线程执行阻塞操做,避免了用户态到内核态的切换。 现代 JDK 中还提供了三种不一样的 Monitor 实现,也就是三种不一样的锁: 偏向锁(Biased Locking) 轻量级锁 重量级锁 这三种锁使得 JDK 得以优化 Synchronized 的运行,当 JVM 检测到不一样的竞争情况时,会自动切换到适合的锁实现,这就是锁的升级、降级。 当没有竞争出现时,默认会使用偏向锁。 JVM 会利用 CAS 操做,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,因此并不涉及真正的互斥锁, 由于在不少应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁能够下降无竞争开销。 若是有另外一线程试图锁定某个被偏斜过的对象,JVM 就撤销偏斜锁,切换到轻量级锁实现。 轻量级锁依赖 CAS 操做 Mark Word 来试图获取锁,若是重试成功,就使用普通的轻量级锁;不然,进一步升级为重量级锁。
问题五:为何说 Synchronized 是非公平锁?
非公平主要表如今获取锁的行为上,并不是是按照申请锁的时间先后给等待线程分配锁的,每当锁被释放后, 任何一个线程都有机会竞争到锁,这样作的目的是为了提升执行性能,缺点是可能会产生线程饥饿现象。
问题六:什么是锁消除和锁粗化?
锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。主要根据逃逸分析。 程序员怎么会在明知道不存在数据竞争的状况下使用同步呢?不少不是程序员本身加入的。 锁粗化:原则上,同步块的做用范围要尽可能小。可是若是一系列的连续操做都对同一个对象反复加锁和解锁,甚至加锁操做在循环体内, 频繁地进行互斥同步操做也会致使没必要要的性能损耗。 锁粗化就是增大锁的做用域。
问题七:为何说 Synchronized 是一个悲观锁?乐观锁的实现原理又是什么?什么是 CAS,它有什么特性?
Synchronized 显然是一个悲观锁,由于它的并发策略是悲观的: 无论是否会产生竞争,任何的数据操做都必需要加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程须要被唤醒等操做。 随着硬件指令集的发展,咱们可使用基于冲突检测的乐观并发策略。先进行操做,若是没有其余线程征用数据,那操做就成功了; 若是共享数据有征用,产生了冲突,那就再进行其余的补偿措施。这种乐观的并发策略的许多实现不须要线程挂起,因此被称为非阻塞同步。 乐观锁的核心算法是 CAS(Compareand Swap,比较并交换),它涉及到三个操做数:内存值、预期值、新值。 当且仅当预期值和内存值相等时才将内存值修改成新值。 这样处理的逻辑是,首先检查某块内存的值是否跟以前我读取时的同样,如不同则表示期间此内存值已经被别的线程更改过,舍弃本次操做, 不然说明期间没有其余线程对此内存值操做,能够把新值设置给此块内存。 CAS 具备原子性,它的原子性由 CPU 硬件指令实现保证,即便用 JNI 调用 Native 方法调用由 C++ 编写的硬件级别指令, JDK 中提供了 Unsafe 类执行这些操做。
问题八:乐观锁必定就是好的吗?
乐观锁避免了悲观锁独占对象的现象,同时也提升了并发性能,但它也有缺点: 乐观锁只能保证一个共享变量的原子操做。若是多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决, 无论对象数量多少及对象颗粒度大小。 长时间自旋可能致使开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。 ABA 问题。CAS 的核心思想是经过比对内存值与预期值是否同样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A, 后来被一条线程改成 B,最后又被改为了 A,则 CAS 认为此内存值并无发生改变,但其实是有被其余线程改过的, 这种状况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。
2. 可重入锁 ReentrantLock 及其余显式锁相关问题
问题一:跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不一样?
其实,锁的实现原理基本是为了达到一个目的:让全部的线程都能看到某种标记。 Synchronized 经过在对象头中设置标记实现了这一目的,是一种 JVM 原生的锁实现方式,而 ReentrantLock 以及全部的基于 Lock 接口的实现类,都是经过用一个 volitile 修饰的 int 型变量,并保证每一个线程都能拥有对该 int 的可见性和原子修改, 其本质是基于所谓的 AQS 框架。
问题二:那么请谈谈 AQS 框架是怎么回事儿?
AQS(AbstractQueuedSynchronizer 类)是一个用来构建锁和同步器的框架,各类 Lock 包中的锁(经常使用的有 ReentrantLock、 ReadWriteLock),以及其余如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基于 AQS 来构建。 1.AQS 在内部定义了一个 volatile int state 变量,表示同步状态:当线程调用 lock 方法时 ,若是 state=0,说明没有任何线程占有 共享资源的锁,能够得到锁并将 state=1;若是 state=1,则说明有线程目前正在使用共享变量,其余线程必须加入同步队列进行等待。 2.AQS 经过 Node 内部类构成的一个双向链表结构的同步队列,来完成线程获取锁的排队工做,当有线程获取锁失败后,就被添加到队列末尾。 Node 类是对要访问同步代码的线程的封装,包含了线程自己及其状态叫 waitStatus(有五种不一样 取值,分别表示是否被阻塞,是否等待唤 醒,是否已经被取消等),每一个 Node 结点关联其 prev 结点和 next 结点,方便线程释放锁后快速唤醒下一个在等待的线程, 是一个 FIFO 的过程。 Node 类有两个常量,SHARED 和 EXCLUSIVE,分别表明共享模式和独占模式。所谓共享模式是一个锁容许多条线程同时操做 (信号量 Semaphore 就是基于 AQS 的共享模式实现的),独占模式是同一个时间段只能有一个线程对共享资源进行操做, 多余的请求线程须要排队等待(如 ReentranLock)。 3.AQS 经过内部类 ConditionObject 构建等待队列(可有多个),当 Condition 调用 wait() 方法后,线程将会加入等待队列中, 而当 Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。 4.AQS 和 Condition 各自维护了不一样的队列,在使用 Lock 和 Condition 的时候,其实就是两个队列的互相移动。
问题三:请尽量详尽地对比下 Synchronized 和 ReentrantLock 的异同。
ReentrantLock 是 Lock 的实现类,是一个互斥的同步锁。 从功能角度,ReentrantLock 比 Synchronized 的同步操做更精细(由于能够像普通对象同样使用),甚至实现 Synchronized 没有的 高级功能,如: 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程能够选择放弃等待,对处理执行时间很是长的同步块颇有用。 带超时的获取锁尝试:在指定的时间范围内获取锁,若是时间到了仍然没法获取则返回。 能够判断是否有线程在排队等待获取锁。 能够响应中断请求:与 Synchronized 不一样,当获取到锁的线程被中断时,可以响应中断,中断异常将会被抛出,同时锁会被释放。 能够实现公平锁。 从锁释放角度,Synchronized 在 JVM 层面上实现的,不但能够经过一些监控工具监控 Synchronized 的锁定,并且在代码执行出现异常时, JVM 会自动释放锁定;可是使用 Lock 则不行,Lock 是经过代码实现的,要保证锁定必定会被释放,就必须将 unLock() 放到 finally{} 中。 从性能角度,Synchronized 早期实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大。 可是在 Java 6 中对其进行了很是多的改进,在竞争不激烈时,Synchronized 的性能要优于 ReetrantLock;在高竞争状况下, Synchronized 的性能会降低几十倍,可是 ReetrantLock 的性能能维持常态。
问题四:ReentrantLock 是如何实现可重入性的?
ReentrantLock 内部自定义了同步器 Sync(Sync 既实现了 AQS,又实现了 AOS,而 AOS 提供了一种互斥锁持有的方式),其实就是加锁 的时候经过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否同样, 同样就可重入了。
问题五:除了 ReetrantLock,你还接触过 JUC 中的哪些并发工具?
一般所说的并发包(JUC)也就是 java.util.concurrent 及其子包,集中了 Java 并发的各类基础工具类,具体主要包括几个方面: 提供了 CountDownLatch、CyclicBarrier、Semaphore 等,比 Synchronized 更加高级,能够实现更加丰富多线程操做的同步结构。 提供了 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者经过相似快照机制实现线程安全的动态数组 CopyOnWriteArrayList 等,各类线程安全的容器。 提供了 ArrayBlockingQueue、SynchorousQueue 或针对特定场景的 PriorityBlockingQueue 等,各类并发队列实现。 强大的 Executor 框架,能够建立各类不一样类型的线程池,调度任务运行等。
问题六:请谈谈 ReadWriteLock 和 StampedLock。
虽然 ReentrantLock 和 Synchronized 简单实用,可是行为上有必定局限性,要么不占,要么独占。实际应用场景中, 有时候不须要大量竞争的写操做,而是以并发读取为主,为了进一步优化并发操做的粒度,Java 提供了读写锁。 读写锁基于的原理是多个读操做不须要互斥,若是读锁试图锁定时,写锁是被某个线程持有,读锁将没法得到,而只好等待对方操做结束, 这样就能够自动保证不会读取到有争议的数据。 ReadWriteLock 表明了一对锁,下面是一个基于读写锁实现的数据结构,当数据量较大,并发读多、并发写少的时候, 可以比纯同步版本凸显出优点:
读写锁看起来比 Synchronized 的粒度彷佛细一些,但在实际应用中,其表现也并不尽如人意,主要仍是由于相对比较大的开销。 因此,JDK 在后期引入了 StampedLock,在提供相似读写锁的同时,还支持优化读模式。优化读基于假设,大多数状况下读操做并不会和写 操做冲突,其逻辑是先试着修改,而后经过 validate 方法确认是否进入了写模式,若是没有进入,就成功避免了开销;若是进入,则尝试获 取读锁。
问题七:如何让 Java 的线程彼此同步?你了解过哪些同步器?请分别介绍下。
JUC 中的同步器三个主要的成员:CountDownLatch、CyclicBarrier 和 Semaphore,经过它们能够方便地实现不少线程之间协做的功能。 CountDownLatch 叫倒计数,容许一个或多个线程等待某些操做完成。看几个场景: 跑步比赛,裁判须要等到全部的运动员(“其余线程”)都跑到终点(达到目标),才能去算排名和颁奖。 模拟并发,我须要启动 100 个线程去同时访问某一个地址,我但愿它们能同时并发,而不是一个一个的去执行。 用法:CountDownLatch 构造方法指明计数数量,被等待线程调用 countDown 将计数器减 1,等待线程使用 await 进行线程等待。 一个简单的例子:
CyclicBarrier 叫循环栅栏,它实现让一组线程等待至某个状态以后再所有同时执行,并且当全部等待线程被释放后,CyclicBarrier 能够 被重复使用。CyclicBarrier 的典型应用场景是用来等待并发线程结束。 CyclicBarrier 的主要方法是 await(),await() 每被调用一次,计数便会减小 1,并阻塞住当前线程。当计数减至 0 时,阻塞解除,全部 在此 CyclicBarrier 上面阻塞的线程开始运行。 在这以后,若是再次调用 await(),计数就又会变成 N-1,新一轮从新开始,这即是 Cyclic 的含义所在。CyclicBarrier.await() 带有 返回值,用来表示当前线程是第几个到达这个 Barrier 的线程。 举例说明以下:
Semaphore,Java 版本的信号量实现,用于控制同时访问的线程个数,来达到限制通用资源访问的目的,其原理是经过 acquire() 获取 一个许可,若是没有就等待,而 release() 释放一个许可。
若是 Semaphore 的数值被初始化为 1,那么一个线程就能够经过 acquire 进入互斥状态,本质上和互斥锁是很是类似的。可是区别也 很是明显,好比互斥锁是有持有者的,而对于 Semaphore 这种计数器结构,虽然有相似功能,但其实不存在真正意义的持有者,除非咱们 进行扩展包装。
问题八:CyclicBarrier 和 CountDownLatch 看起来很类似,请对比下呢?
它们的行为有必定类似度,区别主要在于: CountDownLatch 是不能够重置的,因此没法重用,CyclicBarrier 没有这种限制,能够重用。 CountDownLatch 的基本操做组合是 countDown/await,调用 await 的线程阻塞等待 countDown 足够的次数,无论你是在一个线程 仍是多个线程里 countDown,只要次数足够便可。 CyclicBarrier 的基本操做组合就是 await,当全部的伙伴都调用了 await,才会 继续进行任务,并自动进行重置。 CountDownLatch 目的是让一个线程等待其余 N 个线程达到某个条件后,本身再去作某个事(经过 CyclicBarrier 的第二个构造方法 public CyclicBarrier(int parties, Runnable barrierAction),在新线程里作事能够达到一样的效果)。而 CyclicBarrier 的目的 是让 N 多线程互相等待直到全部的都达到某个状态,而后这 N 个线程再继续执行各自后续(经过 CountDownLatch 在某些场合也能完成类 似的效果)。
3. Java 线程池相关问题
问题一:Java 中的线程池是如何实现的?
在 Java 中,所谓的线程池中的“线程”,实际上是被抽象为了一个静态内部类 Worker,它基于 AQS 实现,存放在线程池 的HashSet<Worker> workers 成员变量中; 而须要执行的任务则存放在成员变量 workQueue(BlockingQueue<Runnable> workQueue)中。 这样,整个线程池实现的基本思想就是:从 workQueue 中不断取出须要执行的任务,放在 Workers 中进行处理。
问题二:建立线程池的几个核心构造参数?
Java 中的线程池的建立其实很是灵活,咱们能够经过配置不一样的参数,建立出行为不一样的线程池,这几个参数包括: corePoolSize:线程池的核心线程数。 maximumPoolSize:线程池容许的最大线程数。 keepAliveTime:超过核心线程数时闲置线程的存活时间。 workQueue:任务执行前保存任务的队列,保存由 execute 方法提交的 Runnable 任务。
问题三:线程池中的线程是怎么建立的?是一开始就随着线程池的启动建立好的吗?
显然不是的。线程池默认初始化后不启动 Worker,等待有请求时才启动。 每当咱们调用 execute() 方法添加一个任务时,线程池会作以下判断: 若是正在运行的线程数量小于 corePoolSize,那么立刻建立线程运行这个任务; 若是正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列; 若是这时候队列满了,并且正在运行的线程数量小于 maximumPoolSize,那么仍是要建立非核心线程马上运行这个任务; 若是队列满了,并且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。 当一个线程完成任务时,它会从队列中取下一个任务来执行。 当一个线程无事可作,超过必定的时间(keepAliveTime)时,线程池会判断。 若是当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。因此线程池的全部任务完成后,它最终会收缩到 corePoolSize 的大小。
问题四:既然提到能够经过配置不一样参数建立出不一样的线程池,那么 Java 中默认实现好的线程池又有哪些呢?请比较它们的异同。
1. SingleThreadExecutor 线程池 这个线程池只有一个核心线程在工做,也就是至关于单线程串行执行全部任务。若是这个惟一的线程由于异常结束, 那么会有一个新的线程来替代它。此线程池保证全部任务的执行顺序按照任务的提交顺序执行。 corePoolSize:1,只有一个核心线程在工做。 maximumPoolSize:1。 keepAliveTime:0L。 workQueue:new LinkedBlockingQueue<Runnable>(),其缓冲队列是无界的。 2. FixedThreadPool 线程池 FixedThreadPool 是固定大小的线程池,只有核心线程。每次提交一个任务就建立一个线程,直到线程达到线程池的最大大小。 线程池的大小一旦达到最大值就会保持不变,若是某个线程由于执行异常而结束,那么线程池会补充一个新线程。 FixedThreadPool 多数针对一些很稳定很固定的正规并发线程,多用于服务器。 corePoolSize:nThreads maximumPoolSize:nThreads keepAliveTime:0L workQueue:new LinkedBlockingQueue<Runnable>(),其缓冲队列是无界的。 3. CachedThreadPool 线程池 CachedThreadPool 是无界线程池,若是线程池的大小超过了处理任务所须要的线程,那么就会回收部分空闲(60 秒不执行任务)线程, 当任务数增长时,此线程池又能够智能的添加新线程来处理任务。 线程池大小彻底依赖于操做系统(或者说 JVM)可以建立的最大线程大小。SynchronousQueue 是一个是缓冲区为 1 的阻塞队列。 缓存型池子一般用于执行一些生存期很短的异步型任务,所以在一些面向链接的 daemon 型 SERVER 中用得很少。 但对于生存期短的异步任务,它是 Executor 的首选。 corePoolSize:0 maximumPoolSize:Integer.MAX_VALUE keepAliveTime:60L workQueue:new SynchronousQueue<Runnable>(),一个是缓冲区为 1 的阻塞队列。 4. ScheduledThreadPool 线程池 ScheduledThreadPool:核心线程池固定,大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。 建立一个周期性执行任务的线程池。若是闲置,非核心线程池会在 DEFAULT_KEEPALIVEMILLIS 时间内回收。 corePoolSize:corePoolSize maximumPoolSize:Integer.MAX_VALUE keepAliveTime:DEFAULT_KEEPALIVE_MILLIS workQueue:new DelayedWorkQueue()
问题六:如何在 Java 线程池中提交线程?
线程池最经常使用的提交任务的方法有两种: 1. execute():ExecutorService.execute 方法接收一个 Runable 实例,它用来执行一个任务:
1. submit():ExecutorService.submit() 方法返回的是 Future 对象。能够用 isDone() 来查询 Future 是否已经完成,当任务完成 时,它具备一个结果,能够调用 get() 来获取结果。也能够不用 isDone() 进行检查就直接调用 get(),在这种状况下,get() 将阻塞, 直至结果准备就绪。
4. Java 内存模型相关问题
问题一:什么是 Java 的内存模型,Java 中各个线程是怎么彼此看到对方的变量的?
Java 的内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。 此处的变量包括实例字段、静态字段和构成数组对象的元素,可是不包括局部变量和方法参数,由于这些是线程私有的, 不会被共享,因此不存在竞争问题。 Java 中各个线程是怎么彼此看到对方的变量的呢?Java 中定义了主内存与工做内存的概念: 全部的变量都存储在主内存,每条线程还有本身的工做内存,保存了被该线程使用到的变量的主内存副本拷贝。 线程对变量的全部操做(读取、赋值)都必须在工做内存中进行,不能直接读写主内存的变量。 不一样的线程之间也没法直接访问对方工做内存的变量,线程间变量值的传递须要经过主内存。
问题二:请谈谈 volatile 有什么特色,为何它能保证变量对全部线程的可见性?
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义成 volatile 以后,具有两种特性: 1.保证此变量对全部线程的可见性。当一条线程修改了这个变量的值,新值对于其余线程是能够当即得知的。而普通变量作不到这一点。 2.禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程当中,获得正确结果,可是不保证程序代码的执行顺序。 Java 的内存模型定义了 8 种内存间操做: lock 和 unlock 把一个变量标识为一条线程独占的状态。 把一个处于锁定状态的变量释放出来,释放以后的变量才能被其余线程锁定。 read 和 write 把一个变量值从主内存传输到线程的工做内存,以便 load。 把 store 操做从工做内存获得的变量的值,放入主内存的变量中。 load 和 store 把 read 操做从主内存获得的变量值放入工做内存的变量副本中。 把工做内存的变量值传送到主内存,以便 write。 use 和 assgin 把工做内存变量值传递给执行引擎。 将执行引擎值传递给工做内存变量值。 volatile 的实现基于这 8 种内存间操做,保证了一个线程对某个 volatile 变量的修改,必定会被另外一个线程看见,即保证了可见性。
问题三:既然 volatile 可以保证线程间的变量可见性,是否是就意味着基于 volatile 变量的运算就是并发安全的?
显然不是的。基于 volatile 变量的运算在并发下不必定是安全的。volatile 变量在各个线程的工做内存, 不存在一致性问题(各个线程的工做内存中 volatile 变量,每次使用前都要刷新到主内存)。 可是 Java 里面的运算并不是原子操做,致使 volatile 变量的运算在并发下同样是不安全的。
问题四:请对比下 volatile 对比 Synchronized 的异同。
Synchronized 既能保证可见性,又能保证原子性,而 volatile 只能保证可见性,没法保证原子性。
问题五:请对比下 ThreadLocal 对比 Synchronized 的异同。
ThreadLocal 和 Synchonized 都用于解决多线程并发访问,防止任务在共享资源上产生冲突。 可是 ThreadLocal 与 Synchronized 有本质的区别。 Synchronized 用于实现同步机制,是利用锁的机制使变量或代码块在某一时该只能被一个线程访问,是一种 “以时间换空间” 的方式。 而 ThreadLocal 为每个线程都提供了变量的副本,使得每一个线程在某一时间访问到的并非同一个对象,根除了对变量的共享, 是一种 “以空间换时间” 的方式。
问题六:请谈谈 ThreadLocal 是怎么解决并发安全的?
ThreadLocal 这是 Java 提供的一种保存线程私有信息的机制,由于其在整个线程生命周期内有效, 因此能够方便地在一个线程关联的不一样业务模块之间传递信息,好比事务 ID、Cookie 等上下文相关信息。 ThreadLocal 为每个线程维护变量的副本,把共享数据的可见范围限制在同一个线程以内,其实现原理是, 在 ThreadLocal 类中有一个 Map,用于存储每个线程的变量的副本。
问题七:不少人都说要慎用 ThreadLocal,谈谈你的理解,使用 ThreadLocal 须要注意些什么?
使用 ThreadLocal 要注意 remove! ThreadLocal 的实现是基于一个所谓的 ThreadLocalMap,在 ThreadLocalMap 中,它的 key 是一个弱引用。 一般弱引用都会和引用队列配合清理机制使用,可是 ThreadLocal 是个例外,它并无这么作。 这意味着,废弃项目的回收依赖于显式地触发,不然就要等待线程结束,进而回收相应 ThreadLocalMap! 这就是不少 OOM 的来源,因此一般都会建议,应用必定要本身负责 remove,而且不要和线程池配合,由于 worker 线程每每是不会退出的。