线程安全的四种策略(threadsafe)

本文介绍了确保线程安全的四种策略:避免交错和竞争条件,限制数据共享,使用不可变数据,以及利用线程安全数据类型和同步机制。通过理解这些策略,可以更好地解决多线程编程中的并发问题。

目录:

前面

我们知道,当不同线程访问同一数据或内存时,就可能会发生交错(interleaving)或竞争(Race conditions)关系。每当这种情况发生时,程序就会发生一些意想不到的bug,而且一旦出现线程相关的bug是很难调试的。

为了避免这些问题的发生,就需要保证线程安全。保证的方式主要有四种,下面我们一起来看一下。

Interleaving and Race Condition

介绍四种线程安全的方法前,首先先来学习两个概念:Interleaving and Race Condition(交错和竞争)

Interleaving(交错)

交错,顾名思义,就是说在线程运行的过程中,多个线程同时运行相互交错。而且,由于线程运行一般不是连续的,那么就会导致线程间的交错。可以说,所有线程安全问题的本质都是线程交错的问题。

Race Condition(竞争)

竞争是发生在线程交错的基础上的。当多个线程对同一对象进行读写访问时,就可能会导致竞争的问题。程序中可能出现的一种问题就是,读写数据发生了不同步。例如,我要用一个数据,在该数据修改还没写回内存中时就读取出来了,那么就会导致程序出现问题。

程序运行时有一种情况,就是程序如果要正确运行,必须保证A线程在B线程之前完成(正确性意味着程序运行满足其规约)。当发生这种情况时,就可以说A与B发生竞争关系。

Confinement(限制数据共享)

保证线程安全的一个最简单也是最直接的方法就是限制数据的共享,即把数据放到单个线程中,避免在可变数据类型上发生竞争关系。其中的核心思想就是不让其他线程直接读或写这个线程中的数据。

使用局部变量保证线程安全

限制数据共享主要是在线程内部使用局部变量,因为局部变量在每个函数的栈内,每个函数都有自己的栈结构,互不影响,这样局部变量之间也互不影响。

如果局部变量是一个指向对象的引用,那么就需要检查该对象是否被限制住,如果没有被限制住(即可以被其他线程所访问),那么就没有限制住数据,因此也就不能用这种方法来保证线程安全。

范例:

public class Factorial {

    /**
     * Computes n! and prints it on standard output.
     * @param n must be >= 0
     */
    private static void computeFact(final int n) {
        BigInteger result = new BigInteger("1");
        for (int i = 1; i <= n; ++i) {
            System.out.println("working on fact " + n);
            result = result.multiply(new BigInteger(String.valueOf(i)));
        }
        System.out.println("fact(" + n + ") = " + result);
    }

    public static void main(String[] args) {
        new Thread(new Runnable() { // create a thread using an
            public void run() {     // anonymous Runnable
                computeFact(99);
            }
        }).start();
        computeFact(100);
    }
}

如该图,在主函数内开启了两个线程,线程调用的是相同的函数。理论上来讲应该会发生竞争关系,然而在这里却没有。因为在该函数中,共享的数据变量是i,n和result,这三个都是局部变量,放在函数调用的栈内,每个函数调用时有不同的栈,因此也就有不同的i,n和result。

用图形的方式可以表示成下面这样:

由于每个函数都有自己的局部变量,那么每个函数就可以独立运行,更新它们自己的函数值,线程之间不影响结果。

避免使用全局变量

由于局部变量的特点,即使对局部变量不做任何处理,线程安全也能得到保证。然而这对于全局变量并不适用。

可以看下面的例子:

// This class has a race condition in it.
public class PinballSimulator {

    private static PinballSimulator simulator = null;
    // invariant: there should never be more than one PinballSimulator
    //            object created

    private PinballSimulator() {
        System.out.println("created a PinballSimulator object");
    }

    // factory method that returns the sole PinballSimulator object,
    // creating it if it doesn't exist
    public static PinballSimulator getInstance() {
        if (simulator == null) {
            simulator = new PinballSimulator();
        }
        return simulator;
    }
}

这个类的形式是属于一种单例模式,在这里,构造器是私有的,只能通过 getInstance 方法来获取这个类的实例,而且,这个类的实例只能有一个。

这个类运行起来看似不会出现什么问题,只返回一个实例。尽管在返回前做了一次判断,然而在多线程程序中,这还是不安全的。

试想,如果有两个线程同时访问这个函数,又紧接着对实例是否为空进行了判断,这时,对于这两个线程来讲,结果肯定都是空的,这时连个线程都去创建该类的对象并返回,这样我们就会得到这个对象的两个实例,这就违反了这个类的规约,程序的正确性就无法得到保证。

除此之外,也可以看下面的例子:

// is this method threadsafe?
/**
 * @param x integer to test for primeness; requires x > 1
 * @return true if x is prime with high probability
 */
public static boolean isPrime(int x) {
    if (cache.containsKey(x)) return cache.get(x);
    boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
    cache.put(x, answer);
    return answer;
}

private static Map<Integer,Boolean> cache = new HashMap<>();

在对cache进行是否包含x检测时,如果在这个时候也同样有两个线程同时对其进行访问,如果一个线程包含了 x ,而后另一个线程紧接着将其中的 x 删除掉了,这样一来,第一个线程的cache.get(x) 方法就不能得到 x 的值,即程序出现了问题,无法满足正确性。

因此,如果仅使用限制数据类型的方法来保证线程安全,就要避免使用全局变量。

Immutability(共享不可变数据)

不可变数据类型,指那些在整个程序运行过程中,指向内存的引用是一直不变的,通常使用final来修饰。不可变数据类型通常来讲是线程安全的,但也可能发生意外。

但是,程序在运行过程中,有时为了优化程序结构,默默地将这个引用更改了。此时,客户端程序员是不知道它被更改了,对于客户端而言,这个引用还是不可变的,但其实已经被悄悄更改了。这时就会发生一些线程安全问题。

解决方案就是给这些不可变数据类型再增加一些限制:

  • 所有的方法和属性都是私有的。
  • 不提供可变的方法,即不对外开放可以更改内部属性的方法。
  • 没有数据的泄露,即返回值而不是引用。
  • 不在其中存储可变数据对象。

这样就可以保证线程的安全了。

Threadsafe data type(共享线程安全的可变数据)

这种方法的核心思想是将可变的数据对象存储在线程安全的数据类型中,这样就可以保证线程的安全性。

在java中最典型的线程安全的两个类型就是 StringBufferStringBuilder ,可以看一下官方文档的介绍:
StringBuffer:

[StringBuffer is] A thread-safe, mutable sequence of characters. A string buffer is like a String, but can be modified. At any point in time it contains some particular sequence of characters, but the length and content of the sequence can be changed through certain method calls.
String buffers are safe for use by multiple threads. The methods are synchronized where necessary so that all the operations on any particular instance behave as if they occur in some serial order that is consistent with the order of the method calls made by each of the individual threads involved.

StringBuilder:

[StringBuilder is] A mutable sequence of characters. This class provides an API compatible with StringBuffer, but with no guarantee of synchronization. This class is designed for use as a drop-in replacement for StringBuffer in places where the string buffer was being used by a single thread (as is generally the case). Where possible, it is recommended that this class be used in preference to StringBuffer as it will be faster under most implementations.

什么意思呢,就是说 StringBuffer 在使用的过程中是线程安全的,但是速度慢;而 StringBuilder 在使用过程中线程不安全,但速度快。官方建议尽量使用 StringBuilder ,以提高速度。

可以看到,java提供了线程安全的数据类型,但不仅仅这一种,还有很多,其中最主要的一个大类就是 Collection 类,像下面这样:

private static Map<Integer,Boolean> cache =
                Collections.synchronizedMap(new HashMap<>());

java提供了一个包装类,可以将线程不安全的类型进一步封装,变成线程安全的数据类型。具体原理在这里就不做详细介绍了。

此外,需要注意的一点就是,在包装数据类型的时候,千万记住不要将引用留在外面,像这样:

private final Set<String> roomNumbers = new HashSet<>();
private final Set<String> floorplan =
                        Collections.synchronizedSet(roomNumbers);

虽然使用了线程安全的封装类,但将 new HashSet<>() 的引用留在了外面,那么就可能发生一些不愉快的事情了。

Synchronization(同步机制共享)

前三种策略的核心思想:
—–> 避免共享
—–> 即使共享,也只能读/不可写(immutable)
—–> 即使可写 (mutable),共享的可写数据应自己具备在多线程之间协调的能力,即“使 用线程安全的mutable ADT”

而这一点可以说是java保证线程安全的主要手段了,这里仅简单介绍,不做详细分析。

一个锁的例子就是监视器模式:

/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
    private String text;
    ...
    public SimpleBuffer() {
        synchronized (this) {
            text = "";
            checkRep();
        }
    }
    public void insert(int pos, String ins) {
        synchronized (this) {
            text = text.substring(0, pos) + ins + text.substring(pos);
            checkRep();
        }
    }
    public void delete(int pos, int len) {
        synchronized (this) {
            text = text.substring(0, pos) + text.substring(pos+len);
            checkRep();
        }
    }
    public int length() {
        synchronized (this) {
            return text.length();
        }
    }
    public String toString() {
        synchronized (this) {
            return text;
        }
    }
}

在每个函数内部,加上 synchronized (this) 这样的话,就是将该对象锁住,每个对象一个时间段只能调用这个对象中的一个方法,而不能同时调用多个,这样就保证了线程的安全性。

同时,使用 synchronized 的话,也要避免死锁,即任何两个线程之间,对对象进行锁的顺序需要一致,如果相反,就很可能会出现死锁的现象。

总结

本文主要介绍java线程安全的四种思想,并没有太深入地说明,希望读完这篇文章的你能对线程安全有更深一层的理解。

如果本文有任何说明不当的地方,欢迎指出,谢谢。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值