Java并发编程学习:线程安全与锁优化
本文参考《深入理解java虚拟机第二版》
一。什么是线程安全?
这里我借《Java Concurrency In Practice》里面的话:当多个线程访问一个对象,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。
我的理解:多线程访问一个对象,任何情况下,都能保持正确行为,就是对象就是安全的。
我们可以将Java语言中各种操作共享的数据分为以下5类:不可变、 绝对线程安全、 相对线程安全、 线程兼容和线程对立。
1.不可变
不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。
Java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。 如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,如果读者还没想明白这句话,不妨想一想java.lang.String类的对象,它是一个典型的不可变对象,我们调用它的substring()、
replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的
不可变的对象的,也就是被声明成fianl的对象,只要被正确构建出来,在不发现this逃逸的情况下,其外部状态永远不会改变,永远不会看到多个线程中处于不一致的状态。也就是说所有对象的共享变量都声明成final ,那么就是安全的。
2.绝对线程安全
绝对的线程安全完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
如果说java.util.Vector是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、
get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。
但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了。如果一个线程要查找i位置的变量,结果另一个线程把他删除了,就会包异常。
抛出异常的原因:因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i 已经不再可用的话,再用i 访问数组就会抛出一个 ArrayIndexOutOfBoundsException
如果要保证这段代码能够正确执行下去,修改后的代码为
// 对线程安全的容器 Vector的测试(修改后的代码) public class ModifiedVectorTest { private static Vector<Integer> vector = new Vector<>(); public static void main(String[] args) { while(true) { for (int i = 0; i < 100; i++) { vector.add(i); } Thread removeThread = new Thread(new Runnable() { @Override public void run() { synchronized (vector) { // 添加同步块,this line for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } } }); Thread printThread = new Thread(new Runnable() { @Override public void run() { synchronized (vector) { // 添加同步块,this line for (int i = 0; i < vector.size(); i++) { System.out.println(vector.get(i)); } } } }); removeThread.start(); printThread.start(); // 不要同时产生过多的线程,否则会导致os 假死 while(Thread.activeCount() > 20); } } }
3.相对线程安全
相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、
HashTable、Collections的synchronizedCollection()方法包装的集合等。
4.线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。
Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
5.线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。 由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
一个线程对立的例子是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。
1.6.3)关于synchronized 和 ReentrantLock 性能的分析:
对上图的分析(Analysis):
A1)多线程环境下 synchronized的吞吐量下降得非常严重,而 ReentrantLock 则能基本保持在同一个比较稳定的水平上;与其说ReentrantLock性能好,还不如说 synchronized还有非常大的优化余地;
A2)虚拟机在未来的性能改进中肯定也会更加偏向于原生的 synchronized,所以还是提倡在 synchronized能实现需求的情况下,优先考虑使用 synchronized 来进行同步;
2.4)如何使用CAS 操作来避免阻塞同步,看个荔枝:(测试incrementAndGet 方法的原子性)
// Atomic 变量自增运算测试(incrementAndGet 方法的原子性) public class AtomicTest { public static AtomicInteger race = new AtomicInteger(0); public static void increase() { // 输出正确结果,一切都要归功于 incrementAndGet 方法的原子性 race.incrementAndGet(); } public static final int THREADS_COUNT = 20; public static void main(String[] args) throws Exception { Thread[] threads = new Thread[THREADS_COUNT]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 10000; j++) { increase(); } } }); threads[i].start(); } while(Thread.activeCount() > 1) { Thread.yield(); } System.out.println(race); } /** * incrementAndGet() 方法的JDK 源码 * Atomically increment by one the current value. * @return the updated value */ public final int incrementAndGet() { for(;;) { int current = get(); int next = current + 1; if(compareAndSet(current,next)) { return next; } } } }
2.5)CAS操作(比较并交换操作)的ABA问题:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就说它的值没有被其他线程改变过了吗? 如果在这段期间它的值曾经被改为了B,之后又改回了A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为 CAS操作的 ABA问题;
2.6)解决方法:J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的version 来保证CAS的正确性。不过目前来说这个类比较鸡肋, 大部分cases 下 ABA问题 不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效;
public class LockEliminateTest { // raw code public String concatString(String s1, String s2, String s3) { return s1 + s2 + s3; } // javac 转化后的字符串连接操作 public String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); } }
对以上代码的分析(Analysis):
A1)对于 javac 转化后的字符串连接操作代码: 使用了同步,因为StringBuffer.append() 方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现他的动态作用域被限制在 concatString() 方法内部;也就是所 sb 的所有引用都不会逃逸到方法之外;
A2)所以,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了;
// javac 转化后的字符串连接操作 public String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
推荐阅读
-
《深入理解Java虚拟机》-----第13章 线程安全与锁优化
-
深入理解Java虚拟机(第三版)-14. 线程安全与锁优化
-
[Java 并发编程实战] 设计线程安全的类的三个方式(含代码)
-
Java并发编程学习:线程安全与锁优化
-
Java多线程编程中并发如何使用锁
-
Java并发编程-Lock锁与生产者消费者问题
-
JAVA并发编程(六):线程本地变量ThreadLocal与TransmittableThreadLocal
-
Java线程安全杂谈——锁、状态依赖与协同以及锁优化
-
Java线程安全杂谈——锁、状态依赖与协同以及锁优化
-
Java多线程/并发06、线程锁Lock与ReadWriteLock