Java并发——线程安全

2,173 阅读7分钟

线程安全的定义

  当多个线程同时访问同一个对象时,调用这个对象的方法都可以得到正确的结果,那么我们称这个对象是线程安全的。 注意,我们所说的线程安全,是指在多线程下,共享数据是否是安全的,而不是线程本身是否安全。
  在Java中我们可以依据线程安全程度将对共享数据的操作划分为:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立这5类。

不可变

  在Java语言中,不可变的对象绝对是线程安全的。那么如何使对象不可变呢? 使对象不可变的方式有很多种,最简单的一种就是将对象中带有带有状态的变量都申明为final类型。

  如果变量是基本数据类型或者字符串类型,那么它是绝对不可变的,但如果变量是对象呢? 在Java语言中,当变量是引用类型时,final修饰符只保证变量在存储内存中的地址不可变,但值是可变的。

绝对线程安全

  在Java语言中,要想实现绝对的线程安全不是那么的容易,一个既定的事实是:尽管Java API中有很多类都申明了自己是线程安全的。但在实际使用中如果不加以额外的措施,是无法做到绝对的线程安全。什么?你不信?
  我们知道,java.util.Vector是一个线程安全的类,我们就用它来实现一个不是线程安全的例子:

public class Test {
    private static Vector<Integer> vector = new Vector<>();
    
    public static void main(String[] args) {
        while(Thread.activeCount() < 2000) {
            for(int i = 0; i < 100; i++) {
                vector.add(i);
            }
            Thread th1 = new Thread(() -> {
                for(int n = 0; n < vector.size(); n++) {
                    vector.remove(n);
                }
            });
            Thread th2 = new Thread(() -> {
                for(int m = 0; m < vector.size(); n++) {
                    System.out.println(m);
                }
            });
            
            th1.start();
            th2.start();
        }
    }
}

运行结果:

Exception in thread "Thread-700" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3799
	at java.util.Vector.remove(Vector.java:831)
	at com.leon.util.Test.lambda$main$0(Test.java:19)
	at java.lang.Thread.run(Thread.java:748)
…………

  很显然,尽管Vector中的size()、add()、remove()这些方法都是同步的,但是在多线程环境中,如果线程A在执行了size()之后,循环遍历到i,此时如果另一个线程B恰好在错误的时间执行了remove操作,导致这个i不可用,这时,A线程执行get(i)方法必然出错。
  为了能够实现绝对的线程安全,我们对代码进行优化如下:

public class Test {
    private static Vector<Integer> vector = new Vector<>();
    
    public static void main(String[] args) {
        while(Thread.activeCount() < 2000) {
            for(int i = 0; i < 100; i++) {
                vector.add(i);
            }
            Thread th1 = new Thread(() -> {
                synchronized(vector) {
                    for(int n = 0; n < vector.size(); n++) {
                        vector.remove(n);
                    }
                }
            });
            Thread th2 = new Thread(() -> {
                synchronized(vector) {
                    for(int m = 0; m < vector.size(); n++) {
                        System.out.println(m);
                    }
                }
            });
            
            th1.start();
            th2.start();
        }
    }
}

  通过以上的改造,vector对象将变得绝对的安全!同时,这段代码执行需要付出巨大的性能消耗代价。

相对线程安全

  上面我们介绍到Java语言中大多数申明自己为线程安全的都不是绝对的线程安全,而是相对线程安全。比如上面例子的Vector、HashTable、集合中的synchronizedCollection()方法等。

线程兼容

  线程兼容指的是,对象本身并不是线程安全的,但是可以通过调用端正确的使用同步手段来确保线程在执行过程中对象是安全的。比如HashMap、ArrayList集合等,它们本身并不是线程安全的,但是可以通过使用同步机制等手段。

线程对立

  线程对立指的是不管调用端是否采用了同步措施,在多线程场景下都无法并发的执行,比如Thread中的suspend()和resume(),一个是尝试去中断线程,一个是尝试恢复线程,在并发场景下无论在调用端是否采取了同步措施,都有可能造成死锁风险。正因如此,suspend()和resume()被申明为废弃。

怎么实现线程安全

  了解了Java中线程安全的定义以及划分级别后,我们再来探讨怎样实现线程安全的几种方式。

互斥同步

  互斥是因,同步是果;互斥是方法,同步是目的。 互斥同步是一种很常见的并发正确性保证手段。同步是指在多个线程下,对于共享数据能够保证同一时刻只会有一个(或者一些 在使用信号量的情况下)使用。而互斥是实现同步的一种手段,比如临界区、互斥量、信号量等常规手段。   在Java语言中,最常见的互斥手段就是synchronized关键字。而在JDK5之后,提供了J.U.C包,其中的java.util.concurrent.locks.Lock接口提供全新的互斥同步手段,比如大家熟悉的ReentrantLock。
  互斥同步面临的问题在于阻塞线程和唤醒线程引起的性能消耗问题。从解决问题的角度来看,互斥同步采取的是悲观锁思想,总是悲观的认为一定会存在线程安全问题,因此不管是否会引起并发问题,先加锁再说。

非阻塞同步

  得益于操作系统硬件指令集的发展,现在我们有了另一种解决思路:基于冲突检测的乐观并发策略。这种方式采用的是乐观锁的思想:乐观的认为不会存在锁竞争问题,因此先执行操作,如果不存在线程安全问题,那么操作就成功了;如果确实存在共享数据的竞争,再进行其他补偿措施,比如自适应自旋等。
  试问一下,为什么非阻塞同步需要依赖于硬件指令集?
  因为我们必须要求操作和冲突检测是原子性的。而原子性的保证只能依赖于操作系统指令集。

  随着计算机系统的发展,某些指令可以将几个动作合并成一个动作完成,并且保证它是原子性的,比如:
  测试并设置(test and set)

  获取并增加(fetch and increment)

  比较并替换(compare and swap)

  加载链条/条件存储(LL/SC)

  Java语言实现非阻塞同步就是依赖了其中一个指令:比较并替换(简称CAS操作),在X86架构下它的指令是xmpxcng.   JDK5之后,Java才开始CAS操作,该操作有Unsafe类的compareAndSwapInt()和compareAndSwapLong()方法实现的,这两个方法都是native方法,可以说CAS操作是J.U.C包的灵魂,因为J.U.C包中的大部分功能都是基于CAS的。

无同步方案

  要实现线程安全必须要进行同步操作吗?非也!   如果能让一个方法压根就不涉及共享数据,那么自然就不需要采用同步操作来保障线程安全。在Java中有两种实现方案。

纯代码

  纯代码不依赖共享变量,系统公共资源,所有的变量都是线程私有的。因此这种代码又称为可重入代码。

线程本地存储

  在Java中,如果一个变量要被多个线程访问,但同时又要保证在同一个线程中安全的使用。则可以使用java.lang.ThreadLocal类来实现线程本地存储的功能。

总结

  本篇文章并没有去探讨线程安全具体的源码实现和原理。而是站在线程安全本身的角度去探讨如何线程安全实现的几种思路。有了这种概括性的思路之后,才会有方向性的去探讨具体源码的实现。比如上文中提及到的synchronized关键字、ReentrantLock、Unsafe、ThreadLocal等。