Java并发编程实战是一本比较经典的书,但并不适合没有什么基础的人来读,书中大量的不常用的概念是不太好理解的。推荐先读另一本比较基础的,由电子工业出版社出版的“实战高并发程序设计”,读完之后再来看这本就比较轻松了。博主的另一篇blog中也对并发编程基础知识做了介绍——并发编程基础。
本系列将分为五个部分进行介绍,包含以下内容: (1)基础概念,(2)安全容器和同步工具,(3)线程框架与任务,(4)活跃性,(5)并发性能与测试,(6)显式锁、原子变量与CAS。
1.1 线程安全性
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。
当多个线程访问同一个共享资源时,才会出现线程安全的问题。方法内部的局部变量永远无需考虑线程安全性,因为局部变量存储于线程栈中,而线程栈是线程私有的,无需共享。
访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步。程序状态的封装性越好,就越容易实现程序的线程安全性。
程序状态实际上是说程序中共享的有状态的变量。而封装越好安全性就越高,这也很好理解,因为封装越好的话,访问这个变量的代码就越可控。否则,可能这个变量在莫名其妙的地方被使用,破坏了线程安全性。
无状态对象一定是安全的。
有状态是指有数据存储功能。有状态的对象就是有实例变量,且实例变量可写的对象,是非线程安全的。无状态对象没有实例变量(或者有无状态的实例变量),是线程安全的。可以从jvm内存模型来理解,对象存储于堆上,如果有可写的变量,那么这个变量将会被所有访问该对象的线程共享,是不安全的。而没有实例变量,或者实例变量只能读不能写(这样的对象就是无状态对象),那么自然没有问题。Spring中注入的由spring管理的bean属于无状态对象,
1.2 原子性
在并发编程基础中说过,Java最底层的原子操作有8个,分别是lock,unlock,read,load,use,assign,store和write。
复合操作不具有原子性,比如i = i+1。在java内存模型中这个逻辑涉及到除lock、unlock之外的全部操作。先从主内存中读取i到工作内存(read,load),接着对i进行+1运算(use,assign),最后要将i写入主内存(store,write)。这些操作中其他线程随时都可以插入执行,导致线程不安全。
加锁可以使得一组语句变得具有原子性。
1.3 加锁机制
加锁机制是Java中用于确保原子性的内置机制。
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块。
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Instrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,在退出同步代码块时自动释放锁。
Java的内置锁是种互斥锁,每次只能有一个线程执行内置锁保护的代码块。因此由内置锁保护的同步代码块会以原子方式执行。
重入
内置锁是可重入的,这意味着获取锁的操作的粒度是线程而不是调用。也就是说,同一个线程在已经对变量加锁的基础上,可以再次访问该变量。
1.4 可见性
在并发编程基础中说过,Java内存模型是围绕着并发过程中如何处理原子性,可见性和有序性三个特征来建立的。
所谓可见性,简单点说,就是一个线程对共享变量的更改对其他线程是可见的。
线程对共享变量的更改,可以缓存在工作内存中,在未写入主内存之前,此更改对其他线程是不可见的。
看一段代码:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run(){
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
正常情况下,运行main方法会输出42。但是这段代码涉及多线程共享变量,是线程不安全的。
主线程读取ready值到工作内存中,然后将其变更为true,如果没有及时将更改后的值刷新回主内存,那么另一个线程读取到的ready值就是false,将一直执行while循环。
这就是不可见带来的影响。
同步机制与可见性
要想解决上述例子中的变量的可见性问题,可以使用同步机制来解决。
在并发编程基础中说过,volatile修饰的变量具有可见性,因为volatile变量修改之后需要立即同步到主内存中。
Sychonized关键字也可以保证可见性,因为unlock操作会将工作内存的变量同步到主内存中。
volatile是一种比sychronized关键字更轻量级的同步机制。Sychronized既可以保证可见性,又可以确保原子性,而volatile只能确保可见性。
1.5 线程封闭
说线程封闭之前首先要知道JVM内存模型中的栈,每个线程工作时都有一个线程栈用于存储栈帧,而栈帧中放的是方法局部变量。线程栈是线程私有的,因此线程栈中的局部变量是线程安全的。
将变量在线程之间互相隔离,不共享,就叫做线程封闭。Java提供了一些机制来维持线程封闭性,如局部变量和ThreadLocal类。
栈封闭
局部变量存储于线程栈中,天然就做到了线程封闭。但要注意,如果公有方法返回了引用类型的局部变量,其他类就可以通过调用该方法获取到局部变量的引用,从而能访问到引用指向的对象,那么线程的封闭性将被破坏,并且导致对象逸出。
ThreadLocal
维护线程封闭性的另一种方式是ThreadLocal,它将每个线程与持有数值的对象关联起来,为每个使用它的线程维护一份单独的拷贝。ThreadLocal提供了set和get访问器,get总是返回由当前线程通过set设置的最新值。
如日期格式工具类SimpleDateFormat是线程不安全的,用ThreadLocal进行线程封闭:
private ThreadLocal<DateFormat> threadLocal = new ThreadLocal<>();
private DateFormat getSimpleDateFormat(String format) {
if (threadLocal.get() == null) {
threadLocal.set(new SimpleDateFormat(format));
}
return threadLocal.get();
}
@Test
public void test() {
System.out.println(getSimpleDateFormat("yyyy-MM").format(new Date()));
}
ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。Spring中很多单例bean中的实例变量就是通过ThreadLocal来进行线程封闭的,从而保证了线程安全性。
1.6 不可变对象
一个对象在创建后其状态不可改变,则这个对象称为不可变对象。
大部分不可变对象满足以下条件:
- 对象本身是final的,不能被子类化。
- 对象的属性为private和final的。
- 不要提供任何可以修改对象状态的方法,包括setter。
- 通过构造器初始化所有成员,不要使this逸出,引用对象赋值通过深拷贝方式。
常用基本类型的封装类型都是不可变类型,如Integer,String等。
不可变对象一定是线程安全的。
事实上不可变对象并不是一定没有办法改变,可以通过反射来改变不可变对象的状态。
不可变对象一定是线程安全的,这句话要深入理解。不是说只要是不可变对象,使用时就不用考虑线程安全性。如一个Integer对象是不可变对象,但在多线程环境中对Integer对象进行读写时是不安全的,因为Integer对象每次变更都会产生一个新的不可变对象。所以不可变对象的线程安全性是针对同一个对象而言的。
注意final与不可变对象是两回事。
1.7 安全地共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭
将对象封闭在线程内被线程独占,且只能被占有它的线程修改。
只读共享
在没有额外的同步情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
不可变对象(和事实不可变对象)的状态不可更改,即是只读的。而在多线程环境中,不涉及写操作就不涉及线程安全问题。
线程安全共享
线程安全的对象在其内部实现同步,因此多个线程都可以通过对象的共有接口来进行访问而不需要进一步的同步。
保护对象
保护对象只能通过特定的锁来访问。被保护的对象包括哪些被线程安全对象封装的对象,以及已发布的并且由某个特定锁保护的对象。