前面我们介绍map和list的时候说过,它们是线程不安全的.

并发环境下,线程安全是必须要关注的问题,所以今天这篇文章就围绕线程安全这个话题来展开.

不过由于这部分的内容比较多且抽象,如果直接展开太多细节可能很难消化,所以今天只是简单谈谈并发中比较基础的概念,常见的线程安全问题以及解决方案,原理先不深究,好从整体上掌握这个方向.

思维导图

concurrent

线程安全

何谓线程安全?

当多个线程访问某个类,或调用某个方法时,不管运行环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的协同或者同步,这个类或方法都能表现出正确的行为,那么就可以说这个类或方法是线程安全的。

那为什么会出现线程不安全的情况呢?

这得从JMM(Java内存模型)说起了

JMM

在JMM中,每个线程不会直接去操作主存,而是先操作线程内的副本.

举个栗子🌰,当线程A和B同时对变量进行喜**+1**操作,假设变量原先的值是10.

那么单线程下它们是顺序执行的,A把10变为11,然后B再把11变为12.

可是在并发环境下,它们也许是’同时’进行的.

A,B同时把副本的10变成11.

然后A把11写回主存,主存变为11,然后A继续运行.

而B暂时并不知道A做了什么,它以为主存中的变量还是10,所以它仍然把副本里的11写回了主存.主存从11变为11(等于没变).

所以B实际上相当于没有操作过,它的操作由于’误会’被抵消了.这就导致线程不安全了.

线程安全性体现在3个方面:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作(Atomic、CAS算法、synchronized、Lock)
  • 可见性:一个主内存的线程如果进行了修改,可以及时被其他线程观察到(synchronized、volatile)
  • 有序性:如果两个线程不能从 happens-before原则 观察出来,那么就不能保证他们的有序性,虚拟机可以随意的对他们进行重排序,导致其观察观察结果杂乱无序(happens-before原则)

为了保障线程安全,其中一项最主要的手段是给临界资源加锁🔒

Java中的锁从各种性质上可分为多对含义相对的锁,比如:

  • 乐观锁/悲观锁
  • 共享锁/互斥锁
  • 公平锁/非公平锁
  • 可重入锁/不可重入锁

此外,根据synchronized的锁状态又可以分出无锁/偏向锁/轻量级锁/重量级锁.

不过,从具体形式上来看,最主要的就是这两个: synchronized和Lock.

synchronized和Lock

它们的区别包括:

  • 前者是关键字,后者为接口
  • synchronized在发生异常时,会自动释放锁,不会有死锁问题,而Lock不会释放,建议在finally中释放锁
  • Lock的子类包括可重入锁ReentrantLock,读写锁ReentrantReadWriteLock等,可通过lockInterruptibly()方法实现可中断性,通过构造方法来实现公平性等.
  • Lock能起到的作用更多,例如中断等待锁的线程,获知是否成功获取锁.
  • ReentrantReadWriteLock可通过读写分离提高并发效率,而synchronized在高并发下效率较低.

不过synchronized经过1.6的优化后(也就是上面说的锁状态)效率提高了.

举个🌰,ConcurrentHashmap原先使用了CAS+分段锁,1.8之后改回了synchronized,这说明官方对于synchronized的效率是有信心的.

乐观锁/悲观锁

悲观锁

悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改

例子 : synchronized,Lock

乐观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

例子 : CAS

共享锁/互斥锁

互斥锁

也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据

例子 : synchronized,ReentrantLock,ReentrantReadWriterLock中的写锁

共享锁

是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据

例子 : ReentrantReadWriterLock中的读锁

公平锁/非公平锁

公平锁

多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。

公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁

多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

非公平锁的优点是可以减少唤起线程的开销整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

例子 : ReentrantLock可通过构造函数里的fair参数来决定是否公平,默认是非公平锁

可重入锁/不可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞,不会自己阻塞自己导致死锁

例子 : synchronized,ReentrantLock

ps : 特地备注在这里,经过1.6优化后synchronized复杂了很多,根据锁的状态不同,它既可以是乐观锁也可以是悲观锁,既可以是共享锁也可以是互斥锁,不需要特地去记它的分类,研究完它的原理之后自然了然于心.

volatile

volatile的作用

  • 保证数据的可见性.
  • 禁止指令重排序
  • 不能保证原子性

volatile做了哪些工作?

  • 当写一个volatile变量时,写完后JMM会把该线程对应的本地内存中的共享变量立即刷新到主内存

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

ThreadLocal

ThreadLocal类用来提供线程内部的局部变量

这种变量在多线程环境下访问(通过getset方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。

ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。

ThreadLocal变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量的传递的复杂度。

上述可以概述为:ThreadLocal提供线程内部的局部变量,在本线程内随时随地可取,隔离其他线程

使用场景

数据库连接、Session管理等

线程池

线程池好处

  • 重用存在的线程,减少对象创建,销毁的开销
  • 可有效控制最大并发线程数
  • 提供定时执行,定期执行等功能.

ThreadPoolExecutor

线程池可以通过ThreadPoolExecutor生成,以下是它的构造函数:

public TheadPoolExecutor(int corePoolSize, int maximumPoolSize,long keepAliveTime,TimeUnit unit,
   BlockingQueue<Runnable> workQueue,
   ThreadFactory threadFactory,
   RejectedExecutionHandler handler) 

Executors

Executors类为我们提供了几种常用的线程池,可以跳过参数设置直接使用.

  • newFixedThreadPool (固定数目线程的线程池) :

  • newCachedThreadPool(线程无限的线程池)

  • newSingleThreadExecutor(单线程的线程池)

  • newScheduledThreadPool(定时或周期执行的线程池)

但是,对于线程的使用必须要慎重,如果不清楚线程池的各种特点就滥用很可能导致严重的线上故障.

因此,Executors这种’傻瓜一键式’创建线程池的做法在阿里开发手册里是被禁止的.

J.U.C

J.U.C是java.util.concurrent工具包的简称,专门负责处理线程与并发相关的问题.

J.U.C的内容包括原子类,并发容器,并发工具以及我们上面讲到的线程池.

原子类

原子类也就是Atomic包下的类.

在并发环境中,共享资源不能使用int,long double等基本类型,而是使用AtomicXXX等类,例如AtomicInteger,AtomicLong等的incrementAndGet ()方法.

最常用到的类包括:

  • AtomicBoolean:以原子更新的方式更新 boolean.
  • AtomicInteger:以原子更新的方式更新 Integer.
  • AtomicLong:以原子更新的方式更新 Long.

并发容器

前面我们提到过Hashmap,ArrayList是线程不安全的,可以在JUC里面找到它们对应的线程安全类.

它们对应的替代关系:

  • ArrayList -> CopyOnWriteArrayList
  • HashSet -> CopyOnWriteArraySet
  • TreeSet -> ConcurrentSkipSet
  • HashMap -> ConcurrentHashMap
  • TreeMap -> ConcurrentSkipListMap

并发工具

前面我们提到的原子类以及并发容器都是为了防止线程安全问题,数据不一致问题的发生而设计.

在并发环境下,完成一个任务可能需要线程之间相互协作,这时候就需要用到并发工具了.

Java的并发工具包括:

  • CountDownLatch : 倒数器(计数器).举个栗子🌰,赛跑比赛中,裁判得等到选手们都抵达终点,他的任务才算完成.假设裁判和选手都是线程,每一个选手线程抵达终点,裁判线程的倒数器就-1,减到0之后裁判在这阶段的工作才算完成,才可以进行下一阶段的工作.
  • Semaphore : 信号量.用于控制资源能够被并发访问的线程数量,以保证多个线程能够合理的使用特定资源.这个大家应该不陌生了,生产者消费者模型就是最典型的应用之一.
  • CyclicBarrier : 栅栏.用于控制线程之间的同步,需要所有线程都到达,然后才能继续执行.赛跑比赛中,所有选手都抵达起跑线之后,比赛才能开始,选手才能开始跑.
  • Exchanger : 用于两个线程在同步点交换资源.举个栗子🌰,无间道看过吧,卧底和阿sir到指定地点交换情报或者证据.为什么要强调同步点呢,因为在不同的时候,线程的资源值是不同的,必须要在正确的时间,才能交换到正确的资源,如果卧底提前把情报放在那里,可能会被掉包.

AQS

队列同步器AQS是AbstractQueuedSynchronizer的简称,是JUC的核心类.

AQS使用了一个int类型的变量表示同步状态,通过内置的FIFO队列来完成线程获取资源的排队工作. AQS是实现锁的关键.简单来说,它为并发包的各个组件加锁解锁提供了底层支持.

基本上,JDK的锁(除了关键字synchronized)都有一个内部类Sync,这个Sync一定是继承AQS的.

CAS

CAS = compare and swap 先比较后交换

CAS是JUC并发包的核心实现,本质是一种乐观锁,自旋锁.

当多线程同时对某资源进行操作时,只能有一个线程操作成功,但不会阻塞其他线程而是通知它们操作失败,最底层调用的是native方法.

该算法有三个操作数:

  • 内存值V,

  • 旧的预期值A,

  • 要修改的新值B

当且仅当A和V的值相同时,才将内存值V更新为新值B,否则什么都不做.

一般情况下,“更新”是一个不断重试的操作(死循环)。

CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:

ABA问题

CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

循环时间长开销大

CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

只能保证一个共享变量的原子操作

对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

安全发布对象

发布对象

使一个对象能够被当前范围之外的代码所使用.

相对的概念 : 对象逸出

一种错误的发布.当一个对象还没有构造完成时,就使它被其它线程所见.

四种方法

  • 在静态初始化函数中初始化一个对象引用.
  • 将对象的引用保存到volatile类型域或者AtomicReference对象中.
  • 将对象的引用保存到某个正确构造对象的final类型域中.
  • 将对象的引用保存到一个由锁保护的域中.

线程不安全类与写法

  • StringBuilder -> StringBuffer
  • SimpleDateFormat -> joda-time or java8的日期api
  • ArrayList,HashSet,HashMap等
  • 非原子性操作,例如先检查再执行: if( condition(a) ) { handle(a); }

后言

本文带大家走马观花地看了一遍并发中比较基础的概念,常见的线程安全问题以及解决方案,意在对于并发编程的全貌有个大致的框图.

当然了,并发是一个很复杂也很庞大的问题,一篇文章是不支持深入到各个细节去研究的.

对于本文所提到的各个组件,各种原理,后续会由一系列文章来展开.