并发理论基础
相关信息
因为 CPU、内存、I/O 设备的速度是有极大差异的,为了充分利用性能,Java 并发编程是可以采用多线程的方式去同时完成多个或同一任务,不因某个设备耗时长而阻塞等待...
并发引发的问题
- 可见性(CPU缓存引起):一个线程对共享变量的修改,另外一个线程能够立刻看到。
- 原子性(分时复用引起):一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 有序性(重排序引起):即程序执行的顺序按照代码的先后顺序执行。
解决并发问题
JMM(Java 内存模型)规范了 JVM 如何提供按需禁用缓存和编译优化的方法。通过使用 volatile、synchronized、final 关键字和 Happens-Before 规则实现。
实现原子性
Java 内存模型只保证了读取和赋值是原子性操作,通过 synchronized 和 Lock 来实现更大范围操作的原子性,保证任一时刻只有一个线程执行该代码块,从而保证了原子性。
实现可见性
普通共享变量被修改之后,什么时候被写入主存是不确定的,其他线程去读取时可能还是旧值,因此无法保证可见性。
Java 提供了 volatile 关键字来保证可见性,当一个共享变量被 volatile 修饰时,修改的值会立即被更新到主存,其他线程需要读取时去内存中读取新值,从而实现了可见性。
另外,通过 synchronized 和 Lock 也能够保证可见性,因为同一时刻只有一个线程获取锁然后执行代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以实现可见性。
实现有序性
通过 volatile 关键字可以保证一定的有序性。另外通过 synchronized 和 Lock 也能保证有序性,每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,从而实现有序性。
Happens-Before 规则
除了用 volatile 和 synchronized 来保证有序性。JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
JMM 是通过 Happens-Before 规则来保证有序性的。
- 单一线程原则(Single Thread rule):在一个线程内,在程序前面的操作先行发生于后面的操作。
- 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 线程加入规则(Thread Join Rule):Thread 对象的结束先行发生于 join() 方法返回。
- 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
- 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
线程安全程度
一个类在可以被多个线程安全的调用就是线程安全的。
线程安全的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。永远也不会看到它在多个线程之中处于不一致的状态,多线程环境下应当尽量使对象成为不可变,来满足线程安全。
不可变的对象:
- final 修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类(Long、Double、BigInteger、BigDecimal)
- 使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合(对原始的集合进行拷贝,对集合进行修改的方法都直接抛出异常)。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全
相对线程安全需要保证一个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就需要在调用端使用额外的同步手段来保证调用的正确性。
如:Vector
、HashTable
.
线程兼容
线程兼容是指一个对象本身并不是线程安全的,可以在调用端在并发环境中正确地使用同步手段来保证安全地使用。Java API 中大部分的类都是属于线程兼容的。
如:ArrayList
、HashMap
.
线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。Java 语言排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
实现线程安全
阻塞同步(互斥同步)
互斥同步属于一种悲观的策略,认为只要不做正确的同步措施,就肯定会出现问题。无论共享数据是否会出现竞争,都要加锁(实际上虚拟机会优化掉不必要的加锁)。
最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
实现方式如:synchronized
、ReentrantLock
.
非阻塞同步
基于冲突检测的乐观并发策略,先进行操作,如果没有其它线程争用共享数据则操作就成功了,否则采取补偿措施(不断重试,直到成功)。
乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
CAS
CAS(Compare-and-Swap)比较并交换:CAS 指令需要有 3 个操作数,内存地址 A、旧值 oldValue 和新值 newValue。当执行操作时,只有当 V 的值等于 oldValue,才将 V 的值更新为 newValue.
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
ABA
ABA 问题:一个变量初次读取的时候是 A 值,而后被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为值从来没有被改变过。
大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,通过控制变量值的版本来保证 CAS 的正确性。
无同步
保证线程安全,并不是一定就要进行同步。如果不涉及共享数据,那就无须任何同步措施。
栈封闭
多线程访问同一个方法的局部变量不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
线程本地存储
线程本地存储(Thread Local Storage),如果数据必须共享,且能保证在同一个线程中执行,就可以把共享数据的可见范围限制在同一个线程之内,这样无须同步也能保证线程之间不出现数据争用的问题。
符合这类应用并不少见,如:
- 消息队列模式(生产者-消费者模式):产品的消费过程尽量在一个线程中消费完。
- 经典 Web 交互模型中的一个请求对应一个服务器线程(Thread-per-Request)的处理方式。
使用 java.lang.ThreadLocal
类实现线程本地存储功能。
注意
在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),避免 ThreadLocal 内存泄漏,甚至造成自身业务混乱。
可重入代码
可重入代码(Reentrant Code),也叫纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等