Thead Safety心得体会

线程安全的含义

如果数据类型或静态方法在从多个线程使用时行为正确,则无论这些线程是如何执行的,并且不需要调用代码的额外协调,则该数据类型或静态方法是线程安全的

  • “行为正确”意味着满足其规范并保持其代表不变性。
  • “无论线程如何执行”意味着线程可能位于多个处理器上,或者在同一处理器上进行计时。
  • “没有额外的协调”意味着数据类型不能给其调用方带来与计时相关的前提条件,例如“在进行过程中你不能调用”。get()set()

例如,还记得迭代器吗?与可变集合一起使用时,它不是线程安全的。的规范规定,在迭代集合的同时,不能修改该集合(除非使用迭代器自己的方法)。这是给调用方设置的与时间相关的前提条件,如果您违反它,则不保证行为正确。IteratorremoveIterator

策略1:坐月子

我们实现线安全的第一种方法是限制。线程限制是一个简单的想法:通过将引用或数据限制在单个线程中,可以避免对可重新分配的引用和可变数据进行竞跑。不要让任何其他线程能够直接读取或写入它们。

由于共享可变状态是争用条件的根本原因,因此限制通过不共享可变状态来解决它。

局部变量始终是线程受限的。局部变量存储在堆栈中,每个线程都有自己的堆栈。一次可能有多个方法调用(在不同的线程中,甚至在单个线程堆栈的不同级别,如果该方法是递归的),但是这些调用中的每一个都有自己的变量私有副本,因此变量本身是有限的。

但要小心 - 变量是线程限制的,但如果它是一个对象引用,你还需要检查它指向的对象。如果对象是可变的,那么我们要检查该对象是否也是受限的 - 不能有可以从任何其他线程访问的对它的引用。

限制是使访问 、 和代码安全的原因,如下所示:niresult

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);
    }
}

避免全局变量

与局部变量不同,静态变量不会自动受到线程限制。

如果你的程序中有静态变量,那么你必须提出一个参数,证明只有一个线程会使用它们,你必须清楚地记录这个事实。更好的是,您应该完全消除静态变量。

下面是一个示例:

// 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()PinballSimulator

要使用线程限制方法修复此竞赛,您需要指定只允许某个线程(可能是“弹球模拟线程”)调用 。这里的风险是Java不会帮助你保证这一点。PinballSimulator.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<>();

此函数存储以前呼叫的答案,以防再次请求它们。这种技术称为记忆化,它是对慢速函数(如精确素数测试)的明智优化。但是现在,从多个线程调用该方法并不安全,其客户端甚至可能没有意识到这一点。原因是静态变量引用的对象由 所有对 的调用共享,并且不是线程安全的。如果多个线程通过调用 同时改变映射,则映射可能会损坏,就像银行帐户在上次读取时损坏一样。如果幸运的话,损坏可能会导致哈希映射深处的异常,例如 or 。但它也可能只是悄悄地给出了错误的答案,正如我们在银行账户示例中看到的那样

策略 2:不变性

实现线程安全的第二种方法是使用不可分配的引用和不可变数据类型。不可变性解决了争用条件的共享可变状态原因,并简单地通过使共享状态不可变来解决它。

声明的变量是不可分配的,并且可以安全地从多个线程访问。您只能读取变量,而不能写入它。要小心,因为这种安全性仅适用于变量本身,我们仍然必须论证变量指向的对象是不可变的。final

不可变对象通常也是线程安全的。我们在这里说“通常”,因为我们目前对不变性的定义对于并发编程来说太宽松了。我们已经说过,如果某个类型的对象在其整个生存期内始终表示相同的抽象值,则该类型是不可变的。但这实际上允许该类型自由地改变其代表,只要这些突变对客户端来说是不可见的。我们已经看到了这个概念的几个例子,称为仁慈突变。缓存、惰性计算和数据结构再平衡是典型的善意突变。

但是,对于并发性,这种隐藏的突变是不安全的。使用善意突变的不可变数据类型必须使用锁(与可变数据类型所需的相同技术)使自身成为线程安全的,我们将在以后的阅读中讨论这一点。

更强的不变性定义

因此,为了确信不可变数据类型在没有锁的情况下是线程安全的,我们需要一个更强的不可变性定义:

  • 无突变体方法
  • 所有字段均为私有和最终字段
  • 代表性曝光
  • 代表中没有任何可变对象的突变 - 甚至没有仁慈突变

如果遵循这些规则,则可以确信不可变类型也将是线程安全的。

策略 3:使用线程安全数据类型

实现线程安全的第三个主要策略是将共享的可变数据存储在现有的线程安全数据类型中。

当 Java 库中的数据类型是线程安全的时,其文档将显式说明该事实。例如,以下是StringBuffer所说的:

[StringBufferis] 线程安全、可变的字符序列。字符串缓冲区类似于 ,但可以修改。在任何时间点,它都包含一些特定的字符序列,但序列的长度和内容可以通过某些方法调用进行更改。String

字符串缓冲区可安全供多个线程使用。这些方法在必要时进行同步,以便任何特定实例上的所有操作都表现得好像它们以某种串行顺序发生,该顺序与所涉及的每个单独线程进行的方法调用的顺序一致。

这与StringBuilder相反:

[StringBuilderis] 一个可变的字符序列。此类提供了与 兼容的 API,但不保证同步。此类旨在用作单个线程使用字符串缓冲区的位置(通常情况)的插入式替换。在可能的情况下,建议优先使用此类,因为它在大多数实现下会更快。StringBufferStringBufferStringBuffer

在 Java API 中,找到两种执行相同操作的可变数据类型已变得很常见,一种线程安全,另一种则不执行线程安全。原因就是此示例所指示的:与不安全类型相比,线程安全数据类型通常会导致性能损失。

非常不幸的是,并且名称如此相似,名称中没有任何迹象表明线程安全是它们之间的关键区别。不幸的是,它们不共享一个公共接口,所以当你需要线程安全的时候,你不能简单地将一个实现换成另一个实现。Java集合接口在这方面做得更好,正如我们接下来将看到的那样。StringBufferStringBuilder

线程安全集合

Java 中的集合接口 – 、 、 – 具有非线程安全的基本实现。您习惯使用的这些实现,即 、 和 ,不能从多个线程中安全地使用。ListSetMapArrayListHashMapHashSet

幸运的是,就像集合 API 提供了使集合不可变的包装器方法一样,它提供了另一组包装方法,使集合成为线程安全的,同时仍然是可变的。

这些包装器有效地使集合的每个方法相对于其他方法都是原子的。原子操作实际上同时发生 - 它不会将其内部操作与其他操作的操作交错,并且在整个操作完成之前,其他线程都看不到该操作的任何效果,因此它看起来永远不会部分完成。

现在,我们看到了一种修复我们之前在阅读中拥有的方法的方法:isPrime()

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

这里有几点。

不要规避包装器。确保丢弃对基础非线程安全集合的引用,并仅通过同步的包装器访问它。这在上面的代码行中自动发生,因为新的代码只传递到其他任何地方,永远不会存储在其他任何地方。(我们在不可修改的包装器中看到了同样的警告:底层集合仍然是可变的,并且引用它的代码可以规避不可变性。HashMapsynchronizedMap()

迭代器仍然不是线程安全的。即使对集合本身(、 、 等)的方法调用现在是线程安全的,从集合创建的迭代器仍然不是线程安全的。因此,您不能使用 或 for 循环语法:get()put()add()iterator()

for (String s: lst) { ... } // not threadsafe, even if lst is a synchronized list wrapper

此迭代问题的解决方案是在需要迭代集合时获取集合的锁,我们将在以后的阅读中讨论。

最后,原子操作不足以防止争用:使用同步集合的方式仍可能具有争用条件。请考虑以下代码,它检查列表是否至少有一个元素,然后获取该元素:

if ( ! lst.isEmpty()) { String s = lst.get(0); ... }

即使您进入同步列表,此代码仍可能具有争用条件,因为另一个线程可能会删除调用和调用之间的元素。lstisEmpty()get()

即使这种方法仍然有潜在的比赛:isPrime()

if (cache.containsKey(x)) return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);

同步映射确保 、 和 现在是原子的,因此从多个线程使用它们不会损坏映射的 rep 不变量。但是这三个操作现在可以以任意方式相互交错,这可能会破坏缓存中需要的不变性:如果缓存将整数x映射到值f,那么当且仅当f为真时,x才是质数。如果缓存在此不变量中失败,则我们可能会返回错误的结果。containsKey()get()put()isPrime

因此,我们必须争辩说,、之间的种族,并且不要威胁到这个不变性。该代码有两条有风险的路径:containsKey()get()put()

  1. 假设返回 true,但随后另一个线程在调用之前改变缓存。这是无害的,因为我们从不从缓存中删除项目 - 一旦它包含 的结果,它将继续这样做。containsKey(x)get(x)x
  2. 或者,假设返回 false,但另一个线程在 之前更改了缓存。最终,这两个线程可能同时测试同一个线程的素数,并且两个线程都将争先恐后地调用答案。但是他们两个都应该用相同的值来调用,所以谁赢得比赛并不重要 - 结果将是相同的。containsKey(x)put(x, ...)xput()put(x, answer)answer

需要对安全性进行这些仔细的争论 - 即使您使用线程安全数据类型 - 也是并发性困难的主要原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小王不累

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值