线程安全性

什么是线程的安全性?

当多个线程访问某个类时,不管运行时环境次啊用何种调度方式或者这些线程将如何交替执行,这个类始终都能表现正确的行为,那么称这个类是线程安全的。

什么是无状态的类?

若一个类中既不包含任何域,也不包含任何对其他类中域的引用,那么我们称这个类为无状态的类

如下,我们模拟一个简单的因数分解Servlet:首先从请求中提取出数值,执行因数分解,然后将结果封装到Servlet的响应中。

@ThreadSafe
public class StatelessFactorizer implements Servlet{
    public void service(ServletRequest req, ServletResponse resp){
        //从请求中获取到数值
        BigInteger i = extractFromRequest(req);
        //执行因数分解
        BigInteger[] factors = factor(i);
        //将结果封装到响应中
        encodeIntoResponse(resp, factors);
    }
}

上面的StatelessFactorizer类为无状态的类,因为它不包含任何域和其他类中域的引用。

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

假设有两个线程A和B对StatelessFactorizer类进行访问,在访问的过程中,A不会影响到B的结果,B也不会影响到A的结果。因为这两个线程并没有共享状态,就如同它们在访问不同的实例。由于线程访问无状态对象的行为不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

在原有的基础上,我们添加一个变量count来记录Servlet请求次数。如下:

@ThreadSafe
public class StatelessFactorizer implements Servlet{
    //用来表示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);
    }
}

这个时候StatelessFactorizer类将不再是“无状态”的,因为它包含了变量count。也就意味着,该类可能是线程不安全的。

我们先来看到service中对count的操作count++,该操作看起来是一个操作,但这个操作并非原子的,因而它不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:

a. 读取count的值
b. count值加一
c. 将计算的结果写入count中

假设有三个线程A,B,C同时访问该类,A调用service方法,执行了count++操作,假设count初始为8,这时B和C都在调用getCount()方法来获取到count值,那么存在以下情况:

1. B,C 在A执行c操作之前调用了getCount()方法,那么B和C获取到的count值都为8;
2. B,C 在A执行c操作之后调用了getCount()方法,那么B和C获取到的count值都为9;
3. B在A执行c操作之前调用了getCount()方法,而C在之后调用,那么B获取的count值为8,而C获取到的是9。
4. B在A执行c操作之后调用了getCount()方法,而C在之前调用,那么B获取的count值为9,而C获取到的是8。

上面的情况说明,线程中不同的交替运行情况将导致count值不同,意味着该类并不是线程安全的。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。

实际上,我们上面所说的4种情况便属于竞态条件,只有前两种情况是我们所希望的,而后两种情况并不是我们所想看见的。

对于竞态条件来说,我们可以采取的办法是”先检查后执行“首先观察某个条件是否为真,然后根据这个观察结果来采用相应的动作。常见的一种情况是延迟初始化

延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,并确保只被初始化一次。

@NotThreadSafe
public class LazyInitRace{
    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance(){
        if (instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

但实际上,这样的方案还是存在竞态条件:假设线程A和B同时执行getInstance,A若看到instance为空,那么会创建一个新的实例对象,同样B也会进行这样的操作。而此时instance是否为空,取决于不可预测的时序,以及线程的调度方式和A需要实例化对象的时间。若B在检查instance是否为空时,A没有完成初始化操作,那么将导致A和B创建两个不同的实例对象。

正确避免竞态条件:

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

我们对之前的Servlet进行修改:

@ThreadSafe
public class StatelessFactorizer implements Servlet{
    //用来表示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包中包含了一些原子变量类,用于实现数值和对象引用上的原子状态转换。通过AtomicLong来替代long类型,能够确保所有对计数器状态的访问都是原子的。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象进行管理,那么这个类仍然是线程安全的。

假设我们希望提升Servlet的性能:将最近的结果缓存起来,当连续两个的请求相同的数值进行因式分解时,可以直接使用上一次的计算结果。

要实现该种缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果

@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet{
    //保存最近的数值
    private final AtomicReference<BigInteger> lastNumber 
        = new AtomicReference<BigInteger>();
    //保存最近的因式分解的结果
    private final AtomicReference<BigInteger[]> lastFactors
        = new AtomicReference<BigInteger[]>();

    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        //若该次请求与上次请求一致,从缓存中取值
        if (i.euqals(lastNumber.get())) {
            encodeIntoResponse(resp, lastFactors.get());
        } 
        //否则,调用方法计算并加入到缓存中
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

为了线程的安全性,我们采用了两个原子引用来保证类不存在竞态条件。但实际上,这样的做法能否保证不存在竞态条件呢?

我们假设线程A,B同时访问该类,并且缓存中的最近记录lastNumber为6,lastFactors为[2,3],现在A请求的数值为8,因为i != 6,因此,会调用factor方法,并更新缓存,lastNumber.set(i),当A执行到这步时,B进来了,判断lastNumber是否为8,结果是为8,然后B就直接获取lastFactors中的值[2,3],获取后,A才执行lastFactors.set(factors)

对于,上面假设的情况,虽然偶然性很大,但是依旧存在这种可能,因为线程的运行是难以预测的!

实际上,出现上面的问题,主要是lastNumber和lastFactors在更新上存在不一致的情况。因此:

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

对于上面采用原子引用无法解决安全性问题,那么应该采取何种措施去解决?

实际上,Java提供了一种内置的锁机制来支持原子性:同步代码块

使用方法如下:

synchronized (lock){
  //访问或修改由锁保护的共享状态
}
  • 每个Java对象都可以用作一个实现同步的锁,这些锁称为“内置锁”。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。
  • Java的内置锁相当于一种互斥体,这意味着最多一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet{
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;

    //使用Synchronized关键字
    public Synchronized void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        //若该次请求与上次请求一致,从缓存中取值
        if (i.euqals(lastNumber)) {
            encodeIntoResponse(resp, lastFactors);
        } 
        //否则,调用方法计算并加入到缓存中
        else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
        
    }
}

如上,我们使用Synchronized关键字来修饰service方法,因此同一时刻只有一个线程来执行service。如此,类必然是线程安全的,但实际上这种方法是过于极端。因为多个客户端无法同时使用因式分解Servlet,这样的服务响应性非常低。

Paste_Image.png

我们对此,做如下修改,并增加了“计数器”和“缓存命中计数器”:

@ThreadSafe
public class SynchronizedFactorizer implements Servlet{
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    //表示请求次数
    @GuardedBy("this") private long hits;
    //表示缓存命中次数
    @GuardedBy("this") private long cacheHits;

    //使用Synchronized关键字获取hits,保证在获取的过程中,其他线程不会改变hits
    public Synchronized long getHits(){
        return hits;
    }

    //使用Synchronized关键字获取命中率,保证在获取的过程中,其他线程不会改变hits和xacheHits
    public Synchronized double getCacheHitRadio(){
        return (double) cacheHits / (double) hits;
    }

    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        //使用Synchronized关键字,保证在取缓存过程中,不会改变lastNumber和lastFactors
        Synchronized (this){
            ++ hits;
            if (i.equals(lastNumber)) {
                ++ cacheHits;
                factors = lastFactors.clone();
            }
        }
        
        if (factors == null) {
            factors = factor(i);
            //使用Synchronized关键字,保证在写缓存中,其他线程无法读取lastNumber和lastFactors
            Synchronized(this){
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
}

实际上,我们知道该类为线程不安全主要是因为lastNumber和lastFactors的读写并发问题。因此,我们只要保证在对lastNumber和lastFactors读写过程中保证只有一个线程即可,而其他时候,则允许多线程并发执行,比如这里的factors方法,如此一来,我们便可以兼并安全性和性能。

内置锁是可重入的

如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

如何实现可重入的内置锁?

为每个锁关联一个获取计数值和一个所有者线程

当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

public class Widget(){
    public synchronized void dosomething(){
        ...
    }
}

public class LoggingWidget extends Widget{
    public synchronized void dosomething(){
        System.out.println(this.toString() + ": calling dosomething");
        //调用父类中的dosomething方法
        super.dosomething();
    }
}

如上,我们设置类Widget和其子类LoggingWidget,并在子类中dosomething方法中调用父类的dosomething方法。

假设,内置锁是不可重入的,我们看会出现什么情况:首先子类中dosomething方法用synchronized修饰,因此会获取到Widget上的锁,然后,执行完输出后,会调用父类的dosomething方法,因为父类的该方法也被synchronized所修饰,因此需要获取Widget的锁,而Widget的锁已经被子类所获取,那么将导致无法执行父类的dosomething方法,同样,子类也是无法执行,导致陷入了死锁。

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

推荐阅读更多精彩内容