java 多线程 串行 加锁_Java并发编程:深刻分析线程加锁机制,多线程开发利器...

当在servlet中添加一个状态变量时,可以通过线程安全的对象来管理servlet的状态以维护servlet的线程安全性。但如果想在servlet中添加更多的状态,那么是否只需添加更多的线程安全状态变量就足够了?

假设我们希望提升servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果。看不懂例子不要紧,看下去,这个列子只是抛砖引玉。

上一篇文章我们通过AtomicLong以线程安全的方式来管理计数器的状态,那么,在这里是否可以使用类似的AtomicReference来管理最近执行因数分解的数值及其分解结果呢?下面的程序实现了这种思想。

0f04639b0b7b295c137624aca98abf72.png

然而,这种方法并不正确,尽管这些原子引用本身都是现成安全的,但在UnsageCachingFactorizer中存在着竞态条件,这可能产生错误的结果。

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏,UnsageCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。只有确保了这个不变性条件不被破坏,上面的servlet才是正确的。当在不变性条件中涉及多个变量的时候,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

在某些执行时序中,UnsageCachingFactorizer可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块。包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。竞态的synchronized方法以Class对象作为锁。

synchronized (lock){

//访问或修改由锁保护的共享状态

}

每个Java对象都可以用做一个实现同步的锁,这些锁被称作为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。获得锁的唯一途径就是进入由这个锁保护的同步代码块或方法,其他工作交给Java吧。

Java的内置锁相当于一种互斥锁,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,知道线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

这种同步机制使得要确保因数分解Servlet的线程安全性变得更简单。我们使用synchronized来修改上面程序的service方法,因此在同一时刻只有一个线程可以执行service方法。

783c484eeb0f0469b84def67466bb2a4.png

然而这种方法却过于极端,因为多个客户端无法同时使用因数分解servlet,服务的响应性非常低,无法令人接受。这是一个性能问题,而不是线程安全问题。

重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应地递减。当计数值为0时,这个锁将被释放。

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。看下面的程序:

52bdd00097825e38b3d268d5b150637e.png

子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。

用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问。因此可以通过锁来构造一些协议来实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

访问共享状态的复合操作,例如计数器的递增操作++count(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么回事复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

对象的内置锁预期状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显示地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。

加锁约定

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式。在这种情况下,对象状态中的所有变量都会被对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

96b27515b1820bd870eaf42696eba6ec.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值