在JAVA语言层面,怎么保证线程安全?

有序性:使用happens-before原则

可见性:可以使用 volatile 关键字来保证,不仅如此,volatile 还能起到禁止指令重排的作用;另外, synchronized 和 final 这俩关键字也能保证可见性。

原子性:可以使用 和 java.util.concurrent.atomic 包中的原子类来保证。

0、三大关键字:

1、CAS算法:

首先,大家应该已经知道,JMM 中不仅有主内存,每个线程还有各自的本地内存。每个线程会先更新自己的本地内存,然后再同步更新到主内存。

那如果多个线程都想要同步更新到主内存怎么办呢?

CAS 就是用来保证这种情况下的线程安全的。当多个线程尝试使用 CAS 同时更新主内存中的同一个变量时,只有一个线程可以成功更新变量的值,其他的线程都会失败,失败的线程并不会挂起,而是会自旋重试。

1.1 具体来说,CAS(Compare And Set):比较并替换,该算法中有重要的三个操作数:

  • 需要读写的主内存位置(某个变量的值)
  • 该变量原来应该有的值(检查值有没有发生变化)
  • 线程想要把这个变量更新为哪个值

1.2 首先线程先读取需要读写的某个变量的值,然后比较当前该变量的值和该变量原来应该有的值:

  • 如果当前该变量的值与原来应该有的值相匹配,那么这个线程就可以将该变量更新为新值
  • 如果当前该变量的值与原来应该有的值不匹配,那就更新失败,开始自旋重试。

1.3 听起来好像有点拗口,其实总结一下就是三步走:

  • 读取
  • 比较
  • 交换

1.4 CAS 存在的三大问题
看起来 CAS 好像很不错,高效地解决了并发问题,但事实上,CAS 仍然存在三大问题:

  • ABA 问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作

2、AQS(队列同步器):

3、Lock接口:

Java 有两套锁实现,一个就是原生的 synchronized 关键字,另一个就是实现了 Lock 接口的类比如 ReentrantLock。那么既然有了前者,为什么还大费力气整出一套新的实现呢?

对于 synchronized 来说,它把锁的获取和释放操作完全隐藏起来了,进入同步块的时候自动尝试去获取锁,退出同步块时候的自动释放锁,也就是说获取锁操作一定是在释放锁操作之前的。

使用 Lock 那样手动获取和释放锁的方式了。

Lock 接口概览

Lock 其实也没啥神秘的,整个接口就 6 个方法:

public interface Lock {

    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    void unlock();

    Condition newCondition();
}
  1. lock():尝试获取锁,获取锁成功后返回
  2. lockInterruptibly():可中断的获取锁。所谓可中断的意思就是,在锁的获取过程中可以中断当前线程
  3. tryLock():尝试非阻塞的获取锁。不同于 lock() 方法在锁获取成功后再返回,该方法被调用后就会立即返回。如果最终获取锁成功返回 true,否则返回 false
  4. tryLock(long time, TimeUnit unit):超时的获取锁。如果在指定时间内未获取到锁,则返回 false
  5. unlock():释放锁
  6. newCondition():当前线程只有获得了锁,才能调用 Condition 接口的 await 方法。Condition 接口本文就先不做详细赘述了

4、并发集合:

与现代的数据结构类库的常见情况一样,Java 集合类也将接口与实现分离,这些接口和实现类都位于 java.util 包下。按照其存储结构集合可以分为两大类:

  • 单列集合 Collection
  • 双列集合 Map

Collection 接口

单列集合 java.util.Collection:元素是孤立存在的,向集合中存储元素采用一个个元素的方式存储。

1)List 的特点是元素有序、可重复。注意,这里所谓有序的意思,并不是指集合中按照元素值的大小进行有序排序,而是说,List 会按照元素添加的顺序来进行存储,保证插入的顺序和存储的顺序一致。

List 接口的常用实现类有:

  • ArrayList:底层数据结构是数组,线程不安全
  • LinkedList:底层数据结构是链表,线程不安全

2)Set 接口在方法签名上与 Collection 接口其实是完全一样的,只不过在方法的说明上有更严格的定义,最重要的特点是他拒绝添加重复元素,不能通过整数索引来访问,并且元素无序。同样的,所谓无序也就是指 Set 并不会按照元素添加的顺序来进行存储,插入的顺序和存储的顺序是不一致的。其常用实现类有:

  • HashSet:底层基于 HashMap 实现,采用 HashMap 来保存元素
  • LinkedHashSet:LinkedHashSet 是 HashSet 的子类,并且其底层是通过 LinkedHashMap 来实现的。

3)Queue 这个接口其实用的不多,可以把队列看作一种遵循 FIFO 原则的特殊线性表(事实上,LinkedList 也确实实现了 DeQueue 接口),Queue 接口是单向队列,而 DeQue 接口继承自 Queue,它是双向队列。

Map 接口 

双列集合 java.util.Map:元素是成对存在的。每个元素由键(key)与值(value)两部分组成,通过键可以找对所对应的值。显然这个双列集合解决了数组无法存储映射关系的痛点。另外,需要注意的是,Map 不能包含重复的键,值可以重复;并且每个键只能对应一个值。

来看 Map 接口的继承体系图:

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_java

Map 有两个重要的实现类,HashMap 和 LinkedHashMap : 

HashMap:可以说 HashMap 不背到滚瓜烂熟不敢去面试,这里简单说下它的底层结构吧。JDK 1.8 之前 HashMap 底层由数组加链表实现,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法” 解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间(注意:将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)。

LinkedHashMap:HashMap 的子类,可以保证元素的存取顺序一致(存进去时候的顺序是多少,取出来的顺序就是多少,不会因为 key 的大小而改变)。

LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构,即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。

线程安全的集合总览

J.U.C 为每一类集合都提供了线程安全的实现,等会往下看各位就会发现很多线程安全的集合都是以 Concurrent 或者 CopyOnWrite 开头的。这里先给大家解释下:

以 Concurrent 开头的集合类通常采用某些比较复杂的算法(以 ConcurrentHashMap 为首,相信各位八股文都已经背恶心了)来保证永远不会锁住整个集合,因此在并发写入时有较好的性能。

而以 CopyOnWrite(COW)开头的集合类采用了 写时复制 的思想来支持并发读。

具体来说,就是我们往一个 COW 集合中添加元素的时候,底层并不是直接向当前的集合中添加,而是先将当前的集合 copy 出一个新的副本,然后在这个副本李添加元素,添加完元素之后,再将原集合的引用指向这个副本。这样做的好处是我们可以对 COW 集合进行并发的读,而不需要执行加锁操作。

所以,CopyOnWrite 集合其实是一种读写分离的集合。

下面我们来捋一下具体都有哪些线程安全的集合:

Collection 接口

先来一张

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_02

1)List:

  • Vector(这个没啥好说的,它就是把 ArrayList 中所有的方法统统加上 synchronized )
  • CopyOnWriteArrayList

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_开发语言_03

2)Set:

  • CopyOnWriteArraySet
  • ConcurrentSkipListSet

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_链表_04

3)Queue:

  • BlockingQueue 接口

        LinkedBlockingQueue

        DelayQueue

        PriorityBlockingQueue

        ConcurrentLinkedQueue

  • TransferQueue 接口

        LinkedTransferQueue

  • BlockingDeque 接口 

  LinkedBlockingDeque

  ConcurrentLinkedDeque

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_jvm_05

Map 接口 

  • HashTable(这个有一些细节需要注意,可以看这里 Hashtable 渐渐被人们遗忘了,只有面试官还记得,不过在线程安全方面其实也没啥好说的,就是把 HashMap 中所有的方法统统加上 synchronized )
  • ConcurrentMap 接口

   ConcurrentHashMap
   ConcurrentSkipListMap

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_06

ConcurrentHashMap

JDK1.7

第一句:ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

第二句:Segment 继承自 ReentrantLock,是一种可重入锁;其中,HashEntry 是用于真正存储数据的地方

第三句:一个 ConcurrentHashMap 包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,当对某个 HashEntry 数组中的元素进行修改时,必须首先获得该元素所属 HashEntry 数组对应的 Segment 锁

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_07

ConcurrentHashMap 采用分段锁(Segment 数组,一个 Segment 就是一个锁)技术,每当一个线程访问 HashEntry 中存储的数据从而占用一个 Segment 锁时,并不会影响到其他的 Segment,也就是说,如果 Segment 数组中有 10 个 元素,那理论上是可以允许 10 个线程同时执行的。

小结
总结下 JDK 1.7 版本下的 ConcurrentHashMap,其实就是数组(Segment 数组) + 链表(每个 HashEntry 是链表结构),存在的问题也很明显,和 HashMap 一样,那就是 get 的时候都需要遍历链表,效率实在太低。

So,JDK 1.8 做了一些结构上的小调整

JDK1.8

不同于 JDK 1.7 版本的 Segment 数组 + HashEntry 链表,JDK 1.8 版本中的 ConcurrentHashMap 直接抛弃了 Segment 锁,一个 ConcurrentHashMap 包含一个 Node 数组(和 HashEntry 实现差不多),每个 Node 是一个链表结构,并且在链表长度大于一定值时会转换为红黑树结构(TreeBin)。

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_jvm_08

那既然没有使用分段锁,如何保证并发安全性的呢?

synchronized + CAS!

简单来说,Node 数组其实就是一个哈希桶数组,每个 Node 头节点及其所有的 next 节点组成的链表就是一个桶,只要锁住这个桶的头结点,就不会影响其他哈希桶数组元素的读写。桶级别的粒度显然比 1.7 版本的 Segment 段要细。

JDK 1.8 没有使用 ReentrantLock 而是改用 synchronized,足以说明新版 JDK 对 synchronized 的优化确有成效。

5、并发工具类:

6、线程池:

三大方法、7大参数、4种拒绝策略

三大方法:

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_09

七大参数 

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_10

 

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_开发语言_11

 

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_java_12

三大方法的源码都是用ThreadPoolExcutor来创建的 

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_链表_13

所以一般实际运用中药用源码的方式去自定义线程池

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_开发语言_14

 四种拒绝策略

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_数组_15

最大线程数如何设置?

分为CPU密集型和IO密集型

CPU密集型:设置CPU核数为最大线程数

IO密集型:程序有15个大型任务,IO特别占用资源,一般设置为大型任务数的二倍

java 新开的线程保证在主流程走完之后 java怎么保证线程的有序性_链表_16