1.定义线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的 —by Brian Goetz in Java Concurrency In Practice
文字是拗口点,毕竟是外包给中国人翻译的,不过从英文思维看,就好理解了。
类要成为线程安全的,首先必须在单线程环境中有正确的行为。如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这个类的对象的操作序列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。
此外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。
正确性与线程安全性之间的关系非常类似于在描述 ACID(原子性、一致性、独立性和持久性)事务时使用的一致性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。
1.2 状态依赖
看代码。尽管 Vector的所有方法都是同步的,但是在多线程的环境中不做额外的同步,就使用这段代码仍然是不安全的。因为如果另一个线程恰好在错误的时间里删除了一个元素,则 get() 会抛出一个ArrayIndexOutOfBoundsException
调get()的先决条件是以 size()的结果要合法,即大于0,vector里要有元素才能get 。这种以一个方法的结果作为另一个方法的输入条件的模式,它就是一个状态依赖,在多线程情况下,必须保证至少在调用这两种方法期间,元素的状态没有改变。
private static Vector<Integer> vector = new Vector<>();
public static void main(String[] args) throws ClassNotFoundException {
while(true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread rm = new Thread(() -> {
for (int i = 0; i < vector.size(); i++) {
//依赖size()方法,确保size不变
vector.remove(i);
}
});
Thread pr = new Thread(() -> {
for (int i = 0; i < vector.size(); i++) {
//依赖size()方法,确保size不变
System.out.println(vector.get(i));
}
});
rm.start();
pr.start();
while (Thread.activeCount() > 20);
}
}
//-------------lock--------------------------------
Thread rm = new Thread(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
// 依赖size()方法,确保size不变
vector.remove(i);
}
}
});
Thread pr = new Thread(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
// 依赖size()方法,确保size不变
System.out.println(vector.get(i));
}
}
});
2. 线程的安全度
Brian Goetz在IBM developWorkers的文章,把线程安全分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
2.1 不可变(Immutable)
在Java语言里,不可变的对象一定是线程安全的,只要一个不可变的对象被正确构建出来,而且没有发生this引用逃逸,那其外部的可见状态永远也不会改变,永远也不会在多个线程中处于不一致的状态。
之前说volatile时,也提过,final可以保证可见性。final变量被初始化后,就不能再次赋值,其值存在方法区的常量池里。
java.lang.String就是典型不可变对象,调用其方法,返回的是新字符串对象,不会影响原来的值。
AtomicInteger,AtomicLong不是final修饰的类,原子类利用了硬件指令,下文会说。
2.2 绝对线程安全
线程安全的对象,具有以下属性:
类的使用规则所规定的约束,在对象被多个线程访问时,仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。
这种线程安全性保证是很严格的,许多类,如 Hashtable 或者 Vector 都不能满足这种严格的定义。
2.3 相对线程安全
这是我们通常意义上说的线程安全,需要保证,对这个对象单独的操作,是线程安全的,调用时,不需要做额外的保障措施。
但是对一些特定顺序的连续调用,如有依赖关系,就可能需要调用时,使用额外的同步手段。
Java大部分的线程安全类都属于这种类型,如Vector,HashTable,Collections的sychronizedCollection()方法包装的集合等
2.4 线程兼容
我们平常说一个类不是线程安全的,绝大多数指的,是这一种情况。
线程兼容,指对象本身不是线程安全的,但可以在调用时,正确地使用同步手段,保证对象在并发环境下的正确性。
Java API大部分的类属于这种,如ArrayList,HashMap
2.5 线程对立
线程对立类是指,不论调用时,是否采取同步措施,都无法在多线程环境中并发使用。
线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的类的行为,这时通常会出现线程对立。线程对立类的一个例子是调用System.setOut(),System.setIn(),System.runFinalizersOnExit(),Thread.suspend,Thread.resume()。
3.实现线程安全的方法
虚拟机提供的同步机制和锁机制
- 同步
- 阻塞同步/互斥同步
- 非阻塞同步
- 锁
3.1 同步
3.1.1 阻塞同步/互斥同步
互斥同步(MutualExclusion & Blocking Synchronization) 比较常见。是一种悲观的并发策略。
同步:多个线程并发访问共享数据时,保证共享数据在同一个时刻,只被一个线程使用(使用信号量时,是一些线程)。
互斥,或者说,阻塞线程,让多余的线程等待,是实现同步的一种手段。
临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是主要的互斥实现方式。
互斥是因,同步是果;互斥是方法,同步是目的。
同步手段
- synchronized
- java.util.concurrent(J.U.C)的ReentrantLock
- 无同步方案
(1)
synchronized关键字编译后,会在同步快前后形成monitorenter
,monitorexit
2条关键字节码指令。这两个字节码都需要一个reference类型的参数,指明加锁和解锁的对象。
如果synchronized明确指定了对象参数,那么这个对象就是Reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去获取对应的对象实例或Class对象作为锁对象。
在执行monitorenter指令时,首先尝试获取对象的锁。
如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;当执行monitorexit指令时将锁计数器-1。当计数器为0时,锁就被释放了。
如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
之前说过,Java的线程实现,是映射到操作系统的原生线程上的,用户态和内核态的切换,耗费CPU很多时间。
(2)
除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步。ReentrantLock比synchronized增加了高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。
等待可中断:当持有锁的线程,长期不释放锁时,正在等待的线程,可以选择放弃等待,对处理执行时间非常长的同步块很有用。
公平锁: 多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized中的锁是非公平的。ReentrantLock默认非公平锁。
绑定多个条件:可以同时绑定多个Condition对象。
3.1.2 非阻塞同步
非阻塞同步(Non-Blocking Synchronization),乐观的并发策略。
互斥同步/阻塞同步最主要的问题,是线程阻塞和唤醒带来的性能问题。
策略上讲,这是一种悲观并发策略,认为不做正确的同步措施,肯定出问题。无论是否共享数据是否真的出现竞争,它都要进行加锁(实际上,虚拟机会优化掉大部分的不必要的锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
非阻塞同步,依赖硬件指令,基于冲突检测,其许多实现都不需要挂起线程。
先进行操作,如果没有其他线程争用数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行其他的
补偿措施,常见的有不断重试,直到成功。
Java原子操作的实现
我们需要操作和冲突检测,这两个步骤具备原子性。
靠什么保证呢?互斥同步吗,那非阻塞同步就没意义了。
—>靠硬件,保证一个从语义上,看起来需要多次操作的行为,只通过一条处理器指令就完成,有:
-
- 测试并设置(Test and Set)
-
- 获取并增加(Fetch and Increment)
-
- 交换(Swap)
-
- 比较并交换(Compare and Swap,传说中的CAS)
-
- 加载链接/条件存储(Load Linked/Store Contional, LL/SC)
前3条,是上世纪已经在CPU指令集,后面2条,是现代CPU新增的。
//极其重要的指令
CAS指令,需要3个操作数
内存位置(Java中,可以简单理解为变量的内存地址,V)
旧的预期值(A)
新值(B)
while(CAS执行时 and V != A):
if(当且仅当V,符合旧预期值A时):
V = B(CPU用新值B更新V)
break
else
(否则不执行更新)
(但不论是否更新V,都返回V的旧值)
注意上述文字。上面的处理过程,是一个原子操作
JDK 1.5后,才可以用CAS,原子类的源码显示,其实现也用到了CAS。该操作由sun.misc.Unsafe
类的compareAndSwapInt()
,compareAndSwapLong()
,compareAndSwapObject()
等几个方法包装提供。
虚拟机内部,對这些方法做了特殊处理,JIT出来的结果,是一条平台相关的CAS指令,没有调用过程,或者,可以认为无条件内联进去了。
J.U.C原子类,comapreAndSet()
,getAndIncrement()
调用了Unsafe类的CAS操作。
public final int incrementAndGet() {
/* for(;;) {
int current = get();
int next = current + 1;
//这里调用了CAS
if (compareAndSet(current, next)) {
return next;
}
}
*/
//Java 8
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//字节码
// Method descriptor #232 (Ljava/lang/Object;JI)I
// Stack: 7, Locals: 6
public final int getAndAddInt(java.lang.Object arg0, long arg1, int arg2);
0 aload_0 [this]
1 aload_1 [arg0]
2 lload_2 [arg1] //复制局部变量到操作数栈
3 invokevirtual sun.misc.Unsafe.getIntVolatile(java.lang.Object, long) : int [359]
6 istore 5
8 aload_0 [this]
9 aload_1 [arg0]
10 lload_2 [arg1]
11 iload 5
13 iload 5
15 iload 4 [arg2]
17 iadd
18 invokevirtual sun.misc.Unsafe.compareAndSwapInt(java.lang.Object, long, int, int) : boolean [369] //CAS操作
21 ifeq 0
24 iload 5
26 ireturn
Stack map table: number of frames 1
[pc: 0, same]
CAS存在的问题
逻辑漏洞
–> 变量V初次读取时为A,准备赋值时,检查到它仍为A,那么,任务变量V的值没有被其他线程修改过
但事实时,有可能V被该过,而刚好检查时,V刚好被该成了A。
这就是CAS的“ABA”问题
J.U.C提供AtomicStampedReference类,通过控制变量值的版本,保证CAS的正确性
如需解决,传统的互斥同步可能更高效率
3.1.3 无同步方案
保证线程安全,不一定要同步。同步只是保证共享数据争用时的正确性手段。
- 可重入代码(Reetrant Code)
- 可在代码执行任何时刻中断,转而执行另一段代码,回来后执行原来的程序,不会出现任何错误
- 不依赖存储在堆上的数据和公用的系统资源,用到的状态量由参数传入,不调用非可重入的方法
- 线程本地存储(Thread Local Storage)
- 把共享数据的可见范围限制在同一个线程内
- 用java.lang.ThreadLocal类实现
- 大部分使用消费队列的架构模式,都尽量在一个线程中消费完产品