并发基础-(一)线程安全性

一、前言

  • 要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享(Shared,多线程同时访问)和可变的(Mutable,变量的值在其生命周期内可发生变化)状态的访问。

  • 一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要是实现的功能。要使得对象是线程安全的,需采用同步机制来协同对象可变状态的访问。

  • Java中的主要同步机制是关键字synchronized,它提供一个独占的加锁方式,但“同步”这个术语包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
不在线程之间共享该状态变量。
将状态变量修改为不可变的变量。
在访问状态变量时使用同步。

  • 访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步,同时也更容易找出变量在哪些条件下被访问。

当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明确的不变性规范都能起到一定的帮助作用。

  • 编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度,即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。

  • 完全由线程安全类构成的程序不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。在任何情况中,只有当类中仅包含自己的状态时,线程安全类才是有意义的。线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。

线程安全性.png

二、什么是线程安全性

  • 在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。

  • 我们可以将单线程的正确性近似定义为“所见即所知(we know it when we see it)”。在对“正确性”给出了一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类为线程安全的。

当多个线程访问某个类时,不管运行的环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确行为,那么就称这个类时线程安全的。

在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

示例

给出了一个简单的因数分解Servlet,这个Servlet从请求中提取数值,执行因数分解,然后将结果封装到该Servlet的响应中。

@ThreadSafe
public class StatelessFactorizer implements Servlet {
  public void service(ServletRequest req,ServletResponse resp){
    BigIntegert i = exctractFromRequest(req);
    BigIntegert [] factors = factor(i);
    encodeIntoResponse(resp,factors);
   }
}

分析:与大多数Servlet相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在与线程栈上的局部变量中,并且只能有正在执行的线程访问。访问StatelessFactorizer 的线程不会影响到另一个访问同一个StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其它线程中操作的正确性,因此无状态对象是线程安全的。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

无状态对象一定是线程安全的。

三、原子性

当我们在一个无状态对象中增加一个状态会怎么样?

假设增加一个“命中计数器”来统计请求个数,简单的做法是直接加long类型的变量,如下(不建议这么做):

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
  private long count = 0 ;

  public long getCount() { return count; }

  public void service(ServletRequest req,ServletResponse resp){
    BigIntegert i = exctractFromRequest(req);
    BigIntegert [] factors = factor(i);
    ++count;
    encodeIntoResponse(resp,factors);
   }
}

尽管它在单线程下可以正常运行,但它是线程不安全的。++count看上去是一个紧凑语法的操作,但它并非原子性,它包含三个独立操作:读取count的值、将值加1、将计算结果写入count(读取-修改-写入 的操作序列)。它依赖于之前的状态。可能造成如下结果:两个线程没同步的情况下,同时对计数器加1,如果原状态为9,则结果为10,实际上应该为11,结果丢失1。

在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,叫做:竞态条件(RaccCondition)。

3.1 竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。即正确的结果依靠运气。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作

本质上这种竞态条件是基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果开始相应动作(创建文件X),但事实上,在观察到这个结果及开始相应操作之间,观察结果可能变得无效(另一个线程已创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

3.2 示例:延迟初始化中的竞态条件

使用“先检查过后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。

例:延迟初始化中的竞态条件

@NotThreadSafe
public class LazyInitRace {
  private ExpensiveObject instance = null;
  
  public ExpensiveObject getInstance() {
    if( instance == null )
          instance = new ExpensiveObject();
    return instance;
  }
}

假定线程A和B同时执行了getInstance。A看到instance为空,因而创建了一个新的ExpensiveObject实例。B同样需要判断instance是否为空。此时instance是否为空要取决于不可预测的时序,包括线程调度方式,以及A需花多久初始化ExpensiveObject并设置instance。

竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能导致严重的问题。

3.3 复合操作

要避免竞态问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取-修改-写入”(例如递增运算)等操作必须是原子的。我们将“先检查后执行”和“读取-修改-写入”等统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

例:使用AtomicLong类型变量来统计已处理请求的数量

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
  private final ActomicLong count = new ActomicLong(0) ;

  public long getCount() { return count.get(); }

  public void service(ServletRequest req,ServletResponse resp){
    BigIntegert i = exctractFromRequest(req);
    BigIntegert [] factors = factor(i);
    count.incrementAndGet();
    encodeIntoResponse(resp,factors);
   }
}

在java.util.concurrent包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。

在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转化情况要更为容易,从而也更容易维护和验证线程安全性。

四、加锁机制

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其它变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

4.1 内置锁

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

synchronized(lock){
  //访问或修改由锁保护的共享状态
}

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

Java的内置锁相当于一种互斥体(互斥锁),这意味着最多只有一个线程能保持这个锁。

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

4.2 重入

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

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

五、用锁来保护状态

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

如果在符合操作的执行过程中持有一个锁。如果会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

由于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

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

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的路径进行同步,使得在该对象上不会发生并发访问。如果添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

当某个变量由说来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一个时刻只有一个线程可以访问这个变量。当类的不可变性条件涉及多个状态变量时,那么还有另外一个需求:在不可变性条件中的每个变量都必须由同一个锁来保护。因此可以在按个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题(Livveness)或性能问题(Performance)。

六、活跃性与性能

SynchronizedFactorizer中的不良并发.png

多个请求同时到达因数分解时:这些请求将排队等待处理。我们将这种Web应用程序称之为不良并发(Poor Concurrency)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

@ThreadSafe
    public class CachedFactorizer implements Servlet {
        @GuardedBy("this") priavte BigInteger lastNumber;
        @GuardedBy("this") priavte BigInteger[] lastFactors;
        @GuardedBy("this") priavte long hits;
        @GuardedBy("this") priavte long cacheHits;
        
        public synchronized long getHits(){ return hits; }
        public synchronized double getCacheHitRatio() {
            return (double) cacheHits / (double) hits;
        }
        
        public void service(ServletRequest req, ServletResponse resp ){
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = null;
            synchronized (this){
                ++this;
                if ( i.equals(lastNumber) ) {
                    ++cacheHits;
                    factors = lastFactors.clone();
                }
            }
            if ( factors == null ) {
                factors = factor(i);
                synchronized (this) {
                    lastNumber = i ;
                    lastFactors = factors.clone();
                }
            }
            encodeIntoResponse(resp , factors);
        }
    } 

代码修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因素分解结果进行同步更新。此外还重新引入“命中计数器”,添加一个“缓存命中计数器”,并在第一个同步代码块中更新这两个变量。由于两个计数器也共享可变状态的一部分,因此必须在所有所有访问他们的位置上都使用同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需同步。

对在单个变量上实现原子操作来说,原子变量是很用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅不会带来混乱,也不会再性能或安全性上带来任何好处,因此在这里不使用原子变量。

重新构造后的CachedFactorizer实现了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解得过细,那么通常并不好,尽管不会破坏原子性。当访问状态变量或者在复合操作的执行期间,CachedFactorizer需要持有锁,但在执行时间较长的因数分解运算之前要释放锁。这样既确保了线程安全性,也不会过多地影响并发性,而且在每个同步代码块中的代码路径都“足够短”。

要判断同步代码块的合理大小,需要在各种设计需要之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但在CachedFactorier中已经说明了,在二者之间通常能找到某种合理的平衡。

通常,在简单性与性能之间存在着互相制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342