Java 多线程:什么是线程安全性

线程安全性

什么是线程安全性

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

在这定义中,最核心的概念是“正确性”。

在计算机世界中,在一段程序工作进行期间,会被不停的中断和切换,对象的属性(数据)可能会在中断期间被修改和变脏。

在 Java 语言中,线程安全性的问题限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说都是完全没有区别的。

如果每个线程中对共享数据(如全局变量、静态变量)只有读操作,而无写操作,一般来说这种共享数据是线程安全的,而如果存在多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。

Java 中的线程安全

Brian Goetz 曾发表过一篇论文,他并没有将线程安全当做一个非真即假的概念,而是按照线程安全的“安全程度”由强至弱来排序,来将 java 语言中的各种操作共享的数据分为以下 5 类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

1.不可变

在 Java 语言中(特指 JDK 1.5 以后,即 Java 内存模型被修正之后的 Java 语言),不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。

在 Java 中,如果共享数据的数据类型不同,保证其不可变的方式也有所不同。

  • 共享数据是基本数据类型:这种情况只需要在定义时使用 final 关键字修饰它就可以保证它是不可变的。

  • 共享数据是一个对象:这种情况需要保证对象的行为不会对其状态产生影响。保证对象行为不会影响自己状态的途径有很多种:
    比如 String 对象,当我们调用 String 对象的 subString()、replace()等方法时都不会影响它原来的值,只会返回一个新构造的字符串对象。又或者我们可以直接将对象中所有的变量都声明为 final。

2.绝对线程安全

绝对线程安全即是完全满足 Brian Goetz 对线程安全的定义,这是个很严格的定义:一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”。

3.相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,在调用时不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证正确性。Java 语言中的大部分线程安全类都属于这种类型,如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装集合等。

4. 线程兼容

线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们通常所说的一个类不是线程安全的,绝大多数时候指的是这种情况。Java API中的大部分类都是属于线程兼容的。比如集合类 ArrayList 和 HashMap 等。

5. 线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。

非线程安全的影响

全局变量的非线程安全

在了解了什么是线程安全之后,我们来看一下在多线程环境下,对非线程安全的共享数据进行操作,会导致什么样的问题。
下面用经典的 Java 多线程模拟卖火车票的问题来进行说明:

public class TicketTest {

    public static void main(String[] args) {
        TicketSaleRunnable runnable = new TicketSaleRunnable();
        Thread t1 = new Thread(runnable, "1号窗口");
        Thread t2 = new Thread(runnable, "2号窗口");
        Thread t3 = new Thread(runnable, "3号窗口");
        
        t1.start();
        t2.start();
        t3.start();
    }

}

class TicketSaleRunnable implements Runnable {
    
    private int tickets = 10; //总票数10张

    public void run() {
        while(true) {
            if(tickets > 0) {
                tickets--;
                Thread.yield(); //让出线程,增加出错几率
                System.out.println(
                    Thread.currentThread().getName() + ",剩余票数:" + tickets);
            }else {
                break;
            }
        }
    }
    
}

输出结果:

1号窗口,剩余票数:9
3号窗口,剩余票数:7
2号窗口,剩余票数:7
1号窗口,剩余票数:6
2号窗口,剩余票数:4
3号窗口,剩余票数:4
1号窗口,剩余票数:3
3号窗口,剩余票数:1
2号窗口,剩余票数:1
1号窗口,剩余票数:0

可以看到当多个线程同时访问余票(全局变量)时,出现了线程不安全的问题,在不同的线程中输出了重复的结果。
下面我们再通过 ArrayList 和 Vector 来进一步分析一下非线程安全所带来的问题,以及产生的原因。

ArrayList 和 Vector 的线程安全性

不安全的 ArrayList

我们经常见到这样的面试题“ArrayList 和 Vector 的区别,HashMap 和 HashTable 的区别,StringBuilder 和 StringBuffer 的区别?”

答案在上文也有所提及:
ArrayList 是非线程安全的,Vector 是线程安全的;
HashMap 是非线程安全的,HashTable 是线程安全的;
StringBuilder 是非线程安全的,StringBuffer 是线程安全的。

下面通过一个示例来展示一下 ArrayList 非线程安全问题:

public class UnsafeTest {

    public static void main(String[] args) throws InterruptedException {
        final List<Integer> list = new ArrayList<Integer>();
        
        new Thread(new Runnable(){

            public void run() {
                for(int i = 0; i < 100; i++) {
                    list.add(i);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
            
        }).start();
        
        new Thread(new Runnable() {
            
            public void run() {
                for(int i = 100; i < 200; i++){
                    list.add(i);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
        
         Thread.sleep(10000);
         
          // 打印所有结果
          for (int i = 0; i < list.size(); i++) {
              System.out.println("第" + (i) + "号元素为:" + list.get(i));
          }
    }
}

运行结果:

Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 109
    at java.util.ArrayList.add(Unknown Source)
    at selfprivate.UnsafeTest$2.run(UnsafeTest.java:32)
    at java.lang.Thread.run(Unknown Source)

即便是我们多尝试几次,使得程序运行成功结束不抛出异常:

第0号元素为:0
第1号元素为:100
第2号元素为:1
···
第8号元素为:106
第9号元素为:6
第10号元素为:null
第11号元素为:107
第12号元素为:108
···
第185号元素为:197
第186号元素为:97
第187号元素为:98
第188号元素为:198
第189号元素为:199
第190号元素为:99

也经常会发现某些位置出现了 null 值的情况,并且 ArrayList 最终的 size 是小于 200 的。

从运行的结果来看,ArrayList 的确是非线程安全的,我们结合 ArrayList 的源码一起分析一下它的问题主要出在哪里:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
        //默认集合容量大小
        private static final int DEFAULT_CAPACITY = 10;
        //ArrayList内部维护的是一个数组来保存元素
        transient Object[] elementData;
        //elementData所存储的元素个数
        private int size;
        ...
        
        public boolean add(E e) {
            ensureCapacityInternal(size + 1);  // 判断内部数组的容量是否足够,是否需要扩容
            elementData[size++] = e;    //将元素保存到数组中,并将 size 自增1
            return true;
        }

}

1. 多个线程同时进行 add 操作可能会导致抛出数组越界 ArrayIndexOutOfBoundsException 的异常
当数组总容量为 10,且当前已保存了 9 个元素(即size=9)时,线程A 进入 add 方法,并调用ensureCapacityInternal方法判断了容量够用,不需要扩容。随后立即执行线程B 的add 方法开,也调用了ensureCapacityInternal判断了此时容量够用,不需要扩容,接着执行线程 A 的elementData[size++] = e 操作,size 变为 10,线程 B 也开始执行这个赋值操作,而 elementData[]数组的最大下标为9,则调用 elemenmt[10] = e,则就抛出了数组越界异常了。

2. ArrayList 集合中某些位置上的值出现了 null 的情况
3. 一个线程的值会覆盖掉另一个线程添加的值
这是因为赋值操作 element[size++] = e 并不是一个原子操作,它可以看成这样两步:

elementData[size] = e;
size = size + 1; //注意这一步也不是原子操作

当线程 A 执行了 elementData[size] = e 之后,即开始执行线程 B 的 elementData[size] = e 操作,此时这两个线程的 size 值都还没有增加,所以 线程 B 的值覆盖掉了 线程 A 的赋值。接着线程 A 执行 size 增加 1 的操作,线程 B 的 size 也加 1,这就导致了 size 一共增加了两次,这样就空出了一个位置,就导致某一位置的值为 null 的情况。

4. ArrayList 集合实际的 size 比期望的 size 值要小
这是因为源码中的递增操作 size++ 并非是原子操作,实际上它包含了三个独立的操作:读取 size 的值,将值加1,然后将计算结果写入 size。这在多线程环境就很容易导致 size 的计算出错。线程 A 读取了 size,在执行加1之前,线程 B 也读取了 size 的值,这两个线程获取的是同样的 size 值,然后这两个线程各自为 size 增加 1,将值写入 size 中,最终得到的 size 也只增加了一次,而不是两次。

安全的 Vector

现在我们把上面的例子中的 ArrayList

final List<Integer> list = new ArrayList<Integer>();

替换为 Vector

final List<Integer> list = new Vector<Integer>();

再次运行程序,输出结果:

第0号元素为:0
第1号元素为:100
第2号元素为:1
第3号元素为:101
第4号元素为:2
第5号元素为:102
···
第195号元素为:197
第196号元素为:198
第197号元素为:98
第198号元素为:199
第199号元素为:99

没有出现 null 值的情况,size 的值也与期望的一样是 200。

从结果来看 Vector 确实是线程安全的。那么Vector是如何保证线程安全的呢?
通过查看 Vector 的源码,可以看到它的 add 方法多了一个 synchronized 修饰符。

public synchronized boolean add(E e) {
	modCount++;
	ensureCapacityHelper(elementCount + 1);
	elementData[elementCount++] = e;
	return true;
}

在下一篇文章我们将学习一下 synchronized 操作符的作用。

posted @ 2019-02-17 16:36  Helldorado  阅读(2080)  评论(0编辑  收藏  举报