线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

Brian Goetz《Java Concurrency In Practice》

一、Java中的线程安全

按照线程安全的“安全程度”从强到弱排序,Brian Goetz提出了可以将Java语言中的各种操作共享的数据分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立 。

1)不可变

Java(jdk1.5以后)中,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确的构建出来,那其外部的可见状态永远也不会变,永远也不会看到它在多个线程中处于不一致的状态。“不可变”带来的安全性是最简单、最纯粹的。

典型的不可变对象如:被final修饰的变量、java.lang.String类的对象及其部分子类(Long、Double、BigInteger、BigDecimal等)、枚举类型

2)绝对线程安全

Brian Goetz给出的线程安全的条件十分苛刻,在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

import java.util.Vector;

public class VectorTestOne {
    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() {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println(vector.get(i));
                    }
                }
            });

            removeThread.start();
            printThread.start();

            //控制线程数量,防止系统假死
            while (Thread.activeCount() > 20);
        }
    }
}

以上为书中所示代码,但笔者反复运行多次并没有成功捕捉到异常,有兴趣的读者可以自行调试。对于自身机器比较有自信的读者可以注释掉控制线程数量的代码。

代码中的问题在于,如果有一个线程在错误的时间删除了一个元素,会导致序号i不可用,再次访问会抛出ArrayIndexOutOfBoundsException。要解决这个问题,只需要在打印线程加锁即可。

synchronized (vector){
    for (int i = 0; i < vector.size(); i++) {
        System.out.println(vector.get(i));
    }
}
3)相对线程安全

先对线程安全即我们通常所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证正确性。

Java中,大部分过的线程安全类都属于这种类型,如:Vector、HashTable、Collections的synchronizedCollections()方法包装的集合。

4)线程兼容

线程兼容指对象本身并不是线程安全的,但是可以通过调用端使用同步手段来保证对象安全。Java API中大部分类都属于线程兼容,如:ArrayList、HashMap等

5)线程对立

线程对立指无论是否采取同步措施,都无碍在多线程环境中并发使用。

常见的例子有Thread类的suspend()和resume()方法,如果有两个类同时持有一个对象,一个尝试中断线程,一个尝试恢复线程,无论是否进行同步操作,都是存在死锁风险的。(这个两个方法已经被声明废弃)。其他对立操作还有System.setIn()、System.setOut() 、System.runFinalizersOnExit() 等。

二、线程安全的实现方法

1)互斥同步

互斥同步(Mutual Exclusion & Syncronization)是常见的一种并发正确性保障手段同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻纸杯一个(或是一些,使用信号量时)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此在“互斥同步”中,互斥是因,同步是果;互斥是方法,同步时目的

Java中常见的互斥同步手段有synchronized关键字java.lang.concurrent(J.U.C)中的重入锁(ReentrantLock)

a)synchronized关键字

synchronized关键字经过过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果指明了对象参数,那就是这个对象的reference,如果没有指明,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

在执行monitorenter指令时,首先尝试获取对象的锁。如果这个对象没被锁定或是当前线程已经有了那个对象的锁把锁的计数器加1;相反,在执行monitorexit时,会将锁的计数器减1,计数器为0时,锁被释放。

注意:synchronized同步块对同一条线程来说是可重入的,不会出现把自己锁死的情况;其次,同步块在已进入的线程执行完之前,会阻塞后面的线程

b)ReentrantLock 重入锁

ReentrantLock重入锁相比synchronized关键字增加了一些功能:

  • 等待可中断:指当持有所的线程长期不释放锁时,正在等待的线程可以选择放弃等待,改为处理其他事情
  • 公平锁:指多个线程在等待同一个锁时,必须赞找申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点。synchronized中的锁是非公平的,ReentrantLock中默认也是非公平的,但可以通过带布尔值的构造函数改变
  • 锁绑定多个条件指一个ReentrantLock对象可以同时绑定多个Condition对象,而synchronized中,锁对象的wait()和niotify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联时,不得不额外添加一个锁,而ReentrantLock则不需要,只需多次调用newCnodition()方法即可
2)非阻塞同步

互斥同步的最主要问题就是进行线程阻塞和唤醒所带来的的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)

互斥同步属于悲观并发策略,无论共享数据是否真的会出现竞争,它都啊哟进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程等待唤醒等操作。

非阻塞同步(Non-Blocking Synchronization)属于乐观并发策略,先进行操作,如果没有其他线程争用共享数据,操作成功;如果共享数据有争用,产生了冲突,那就再采取补偿措施(不断重试,直到成功)

乐观并发策略需要硬件指令集的支持,常见的一条指令能完成(语义上)多次操作的处理器指令有:

  1. 测试并设置(Test-and-Set)
  2. 获取并增加(Fetch-and-Increment)
  3. 交换(Swap)
  4. 比较并交换(Compare-and-Swap,CAS)
  5. 加载连接/条件存储(Load-Linked/Store-Conditional,LL/SC)

jdk1.5之后,Java程序才可以使用CAS操作,由sun.misc.Unsafe类里的compareAndSwapInt()和compareAndSwapLong()等方法提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条CAS指令,没有方法调用过程。

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicTest {
    private static AtomicInteger race = new AtomicInteger(0);

    //自增方法
    public static void increase(){
        race.incrementAndGet();
    }

    //设置线程组数量
    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        //线程组
        Thread[] threads = new Thread[THREAD_COUNT];
        //循环开辟线程
        for (int i = 0; i < THREAD_COUNT; 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()>2)
            Thread.yield();

        System.out.println(race);
    }
}
运行结果

我们通过以上代码进行race的自增,在使用AtomicInteger代替int后,程序输出了正确结果,这是源于自增方法中incrementAndGet()的原子性。

3)无同步方案

要保证线程安全,不一定要进行同步,有些代码天生就是线程安全的。

a)可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用),而在控制权返回后,原来的城区你不会出现任何错误。

特征:
不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入方法等。如果一个方法,它的返回值可以预测,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求。

所有的可重入的代码都是线程安全的,但并非所有线程安全的diamante都是可重入的。

b)线程本地存储(Thread Local Storage)

一段数据必须与其他代码共享的代码中,如果能保证共享数据能在同一个线程中执行,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无需同步也能保证线程之间不出现数据争用。

Java中可以使用java.lang.ThreadLocal类来实现本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.ThreadLocalHashCode为键,以本地线程变量为值的K-V对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个 ThreadLocal对象包含了一个独一无二的ThreadLocalHashCode值,用来找回对应的本地线程变量。