JAVA并发编程实践 原子性

我们向无状态对象中加入一个状态元素会怎样?假设我们想要添加“命中数(hit counter)”来计算处理请求的数量。显而易见的方法是在Servlet中加入一个long类型的域,并在每个请求中递增它。如同清单2.2的 UnsafeCountingFactorizer所示。
清单2.2  Servlet计算请求数量而没有必要的同步(不要这样做)
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    public long getCount() { return count; }
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}
很遗憾,UnsafeCountingFactorizer并非线程安全的,尽管它在单线程的环境中运行良好。正如第6页中的UnsafeSequence,它很容易遗失更新(lost updates)。自增操作++count由于其紧凑的语法格式,看上去更像一个单独的操作。然而,它不是原子操作。
这意味着,它不能作为一个单独的、不可分割的操作去执行。相反,自增操作是3个离散操作的简写形式:获得当前值,加1,写回新值。这是一个“读-改-写(read-modify-write)”操作的实例,其中,结果的状态衍生自它先前的状态。
第6页的图1.1演示了两个线程在缺乏同步的条件下,试图同时更新一个计数器时所发生的事情。假设计数器的初始值为9,在某些特殊的分时里,每个线程都将读它的值,并看到值是9,然后同时加1,最后都将counter设置为10。很明显,这不是我们期望发生的事情:一次递增操作凭空消失了,一次命中计数被永久地取消了。
你可能会想命中计数上的轻微错误所导致准确率的误差,在基于Web的服务中是可以接受的。有时的确如此。但是如果计数器用于生成序列或对象唯一的标识符,多重调用返回相同的结果会导致严重的数据完整性问题3。在一些偶发时段里,出现错误结果的可能性对于并发程序而言非常重要,以致于专门用一个名词来描述它们:竞争条件。
2.2.1  竞争条件
UnsafeCountingFactorizer中存在数个竞争条件,导致其结果是不可靠的。当计算的正确性依赖于运行时中相关的时序或者多线程的交替时,会产生竞争条件;换句话说,想得到正确的答案,要依赖于“幸运”的时序4。最常见的一种竞争条件是“检查再运行(check-then-act)”,使用一个潜在的过期值作为决定下一步操作的依据。
在现实生活中,我们也常常会遇到竞争条件。比如说你打算中午到University Avenue的星巴克去见一个朋友。不过当你到达这里后,发现这里有两个星巴克,而你并不确定和朋友约在哪一个星巴克见面。12:10的时候,你还没有在星巴克A见到你的朋友,于是你向星巴克B走去,看看他是否在那里,可惜也不在。这存在几种可能性:你的朋友迟到了,没有出现在任何一个星巴克中;你的朋友在你离开后到达了星巴克A;你的朋友曾经在星巴克B,但是为了找你,现在在去星巴克A的途中。接下来,让我们假设最糟糕的情况,不妨称其为最终的可能性。现在是12:15,你们已经去过了所有的星巴克,你们也想知道对方是否已经等在那里了。你现在做什么?回到另一个星巴克?你打算走上多少个来回?
除非你和你的朋友间有某些约定,否则你们会无精打采,倍感沮丧地在University Avenue走上一整天。
“我打起精神沿街走,看朋友是不是在另一处。”这种作法的问题在于当你沿街走时,你的朋友可能已经离开了。你在星巴克A寻找朋友,发现“他不在这里”,然后继续寻找他。你在星巴克B可以做完全相同的事,但不是在同时做。沿街走要花几分钟,在这几分钟的时间里,系统状态可能已经更改。
星巴克的例子阐释了竞争条件的诱因:为获取期望的结果(见到你的朋友),需要依赖相关的事件的分时(当你到达一家星巴克时,会在这里等上多久而后离开,等等)。只要你一走出星巴克A的大门,“朋友不在这里”的观察结果就会潜在地变为无效结果:你的朋友可能已经从后门走进来而你并不知道。这些无效的观察结果,指出了大多数竞争条件的特点——使用潜在的过期观察值来作决策或执行计算。这种竞争条件被称作检查再运行(check-then-act):你观察到一些事情为真(文件X不存在),然后(then)基于你的观察去执行一些动作(创建文件X);但事实上,从观察到执行操作的这段时间内,观察结果可能已经无效了(有人在此期间创建了文件X),从而引发错误(非预期的异常,重写数据或者破坏文件)。
2.2.2  示例:惰性初始化中的竞争条件
检查再运行的常见用法是惰性初始化(lazy initialization)。惰性初始化的目的是延迟对象的初始化,直到程序真正使用它,同时确保它只初始化一次。清单2.3示范了惰性初始化的用法,getInstance方法首先检查ExpensiveObject是否已被初始化,如果是,返回已经存在的实例;否则就创建一个新实例,然后保留它的引用,最后将它返回。由此,在这之后的调用可以避免执行代价昂贵的代码路径。
清单2.3  惰性初始化中存在竞争条件(不要这样做)
@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;
    public ExpensiveObject getInstance() {
        if (instance == null)
            instance = new ExpensiveObject();
        return instance;
    }
}
LazyInitRace中的竞争条件会破坏其正确性。比如说线程 AB同时执行getInstance, A看到instance 是null,并实例化一个新的ExpensiveObject。同时 B也在检查instance是否为null。此时此刻的instance是否为null,这依赖于时序,这是无法预期的。它包括调度的无常性,以及 A初始化ExpensiveObject并设置instance域的耗时。如果 B检查到instance为null,两个getInstance的调用者会得到不同的结果。然而,我们期望getInstance总是返回相同的实例。
UnsafeCountingFactorizer中的命中计数操作中还存在另一种竞争条件.“读-改-写”操作,比如递增计数器,它按照对象先前的状态来定义对象的状态转换。递增一个计数器,你必须要知道先前值,并且要确保你在更新的过程中,没有其他线程改变或使用计数器的值。
像大多数并发错误一样,竞争条件并不总是导致失败:还需要某些特殊的分时。但是竞争条件会引起严重的问题。如果LazyInitRace用于实例化一个应用级的注册器,让它在多次调用中返回不同的实例,会引起注册信息的丢失,或者多个活动得到不一致的已注册对象集合的视图。如果UnsafeSequence用于为持久性框架生成实体标识符,两个对象会由于相同的ID而消亡,因为它们破坏了标识符的完整性约束。
2.2.3  复合操作
LazyInitRace和 UnsafeCountingFactorizer都包含一系列操作,相对于在同一状态下的其他操作而言,必须是原子性的或不可分割的。为了避免竞争条件,必须阻止其他线程访问我们正在修改的变量,让我们可以确保:当其他线程想要查看或修改一个状态时,必须在我们的线程开始之前或者完成之后,而不能在操作过程中。

假设有操作 AB,如果从执行 A的线程的角度看,当其他线程执行 B时,要么 B全部执行完成,要么一点都没有执行,这样 AB互为原子操作。一个原子操作是指:该操作对于所有的操作,包括它自己,都满足前面描述的状态。

如果UnsafeSequence中的自增是原子操作,那么就不会发生第6页图1.1所阐释的竞争条件。每次执行自增,都会产生预期的结果,即计数器准确地加1。为了确保线程安全,“检查再运行”操作(如惰性初始化)和读-改-写操作(如自增)必须是原子操作。我们将“检查再运行”和读-改-写操作的全部执行过程看作是复合操作:为了保证线程安全,操作必须原子地执行。我们会在下一节考虑用Java内置的原子性机制——锁。现在,
我们先用其他方法修复这个问题——使用已有的线程安全类,如清单2.4的Counting- Factorizer所示。
清单2.4  Servlet使用AtomicLong统计请求数
@ThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0);
    public long getCount() { return count.get(); }
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}
java.util.concurrent.atomic包中包括了原子变量(atomic variable)类,这些类用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类型的,我们可以确保所有访问计数器状态的操作都是原子的5。计数器是线程安全的了,而计数器的状态就是Servlet的状态,所以我们的Servlet再次成为线程安全的了。
我们可以向Factoring Servlet中加入一个计数器,并利用已有的线程安全类(AtomicLong)管理计数器的状态,维护Servlet的线程安全性。当只向无状态类中加入唯一的状态元素,而这个状态完全被线程安全的对象所管理,那么新的类仍然是线程安全的。但是,正如我们在下一节所见的,状态的数量从一个增加到多个的情况,远远不像从0个增加到1个这么简单。

利用像AtomicLong这样已有的线程安全对象管理类的状态是非常实用的。相比于非线程安全对象,判断一个线程安全对象的可能状态

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值