Java并发编程实战——第二章 线程安全性

系列文章目录


第一部分 基础知识

第二章 线程安全性

  • 线程安全的核心在于对状态访问操作的管理,尤其是对共享的可变的状态的访问

    对象的状态: 非正式意义上讲,指存储在状态变量(实例或者静态域)中的数据,同时对象的状态可能包括对其他依赖对象的域,例如某个HashMap的状态不仅存储在HashMap本身,还存储在许多Map.Entry对象中,对象的状态包含了任何可能影响其外部可见行为的数据

    共享: 变量可以由多个线程同时访问

    可变: 变量的值在生命周期内可以发生变化

  • 对象的安全,取决于被访问的方式,而不是对象需要实现的功能

  • 当多个线程访问某个状态变量,且其中有线程会执行写入的操作的时候,必须采取同步机制来保证线程安全

  • Java中主要的同步机制:

    • synchronized 关键字 内部锁(JDK6以后引入了偏向锁和轻量级锁,对synchronized进行了锁升级的优化)
    • volatile关键字
    • 显示锁
      • JUC包下java.util.concurrent.locks包下的锁
    • 原子变量
      • JUC包下java.util.concurrent.atomic包下提供的各种原子类
  • 在多线程访问的模型下,要保证线程安全有以下三种情况

    1. 将状态变量使用final修饰,变成不可变的变量
    2. 不在线程之间共享变量
    3. 在访问状态变量的时候使用同步手段
  • Java语言并没有强制要求将所有状态都封装在类中,我们完全可以将状态存储在公开的某个域中,或提供一个内部对象的公开引用,但是这样就增大了线程安全性的实现难度,结论是封装性越好,越容易实现线程安全,当设计线程安全的类时,良好的面向对象技术,不可修改性,以及明晰的不变性规范都能起到帮助作用

2.1什么是线程安全性

  • 正确性:某个类的行为与规范完全一致

    良好的规范中会定义:

    • 不变性条件,约束对象状态
    • 后验条件,描述对象操作的结果
  • 线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

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

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

    下面是一个无状态的Servlet的示例,它提供了一个简单的分解质因数的服务

    @ThreadSafe//线程安全(这是JCIP这本书中用来标识用的注解,真想用需要引入依赖)
    public class StatelessFactorizer implements Servlet {
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);// 解析ServletRequest获取数据,后面就不单独注释
            BigInteger[] factors = factor(i);// 分解质因数
            encodeInttoResponse(resp, factors);// 封装ServletResponse对象
        }
    }
    

    这样一个Servlet就是一个无状态的Servlet,每一次发送的请求或者是收到的响应都是独立的且没有互相影响,因此在多线程访问调用时,不存在共享状态的情况,因此时线程安全的(上述线程安全条件第二点)。

    大多数Servlet都是无状态的,这样极大的降低了Servlet线程安全性的复杂性,只有当Servlet在处理请求需要保存一些信息的时候,线程安全才会成为一个问题

2.2原子性

当我们给无状态的Servlet添加一个状态,即我们希望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;
        encodeInttoResponse(resp, factors);
    }
}

以上的Servlet计数在多线程访问的情况下会出现计数不准确的情况,这就时”不正确“的,因此是不安全的,而出现计数不准确的原因关键在++运算符,这并不是一个原子的运算符

++的操作在CPU指令层面对应了三个指令

  1. 读取count的值
  2. 修改count的值
  3. 写入count的值

因此,存在一种情况是当两个线程同时读到了count为9,因此最后写入的时候都是写入10,而理论上两次访问过后结果应该是11,每多一次这样的冲突,计算就会偏差一个1,可能在这个业务场景下计数的偏差影响并不是致命的,但是该计数器被用来生成数值序列或者唯一的对象标识符的时候就不会允许存在同样的序列或者标识符了

在并发编程中,像这种由于不恰当的执行时许而出现不正确的结果是一种非常重要的情景,有一个正式的名字叫竞态条件,而导致竞态条件的代码成为临界区

2.2.1竞态条件
  • 竞态条件(Race Condition)与数据竞争(Data Race)

竞态条件,指的是由于执行顺序差别导致结果无法预测的情况

数据竞争,指的是由于信息不同步导致读写产生误差

简单来说,竞态条件更关注操作的原子性,由于没有保证原子性导致的线程不安全;而数据竞争更关注操作的状态的可见性,由于多个线程之间可见性得不到保证而导致的线程不安全

典型的数据竞争:

public class DataRace {
    private long count;
    public void set(long newCount){
        count=newCount;
    }
    public long get(){
        return count;
    }
}

当存在多个线程同时调用set和get方法时,可能在某个线程set执行成功后其他线程get到的值仍然是旧值,解决方法也很简单

private volatile long count;

给共享的状态变量count加一个volatile关键字就可以保证可见性,即某线程修改完后其他线程获得的值都是更新后的值

典型的竞态条件:

public class RaceCondition {
    private Long count;
    private void increase(){
        count++;
    }
}

这也是上一节中提到的++的例子,这个例子里即存在竞态条件,其实也存在数据竞争,修改方法就是使用原子类

private AtomicLong count = new AtomicLong(0);
private void increase() {
    count.addAndGet(1);
}

总结一下,这里存在的竞态条件类型,也是最常见的类型:先检查,后执行,即通过一个错误/失效的值进行下一步操作

2.2.2延迟初始化中的竞态条件

使用先检查后执行的常见”应用“就是延迟初始化,也就是单例模式中的懒汉模式

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

这种形式的单例模式在多线程环境下是不安全的,因为没有办法保证单例,原因也是由于这个check的部分,当多个线程同时check发现instance为空时,就都会新建instance,破坏了单例

2.2.3复合操作

以上出现的问题,不管是先检查后执行,还是读取——修改——写入的模型,想要解决都只需要保证操作的原子性,当我执行结束或者写入结束之前,其他线程是无法检查或者读取的时候,这种竞态条件就被破坏了,线程安全就得到保证了

原子操作:有两个操作A和B,在执行A的线程来看,执行B操作的线程要么完全不执行,要么全部执行完,那么A和B彼此来说就是原子的

Java中保证原子性的方式有很多,可以使用2.3节介绍的加锁机制来保证语句的原子性,或者对于UnsafeCountingFactorizer这种保证数字增减原子性就可以使用之前提到过的原子类型的数据类型AtomicXxx

在2.2.1中演示了AtomicLong的用法,这里就不展示修改的代码,同时需要注意的是,实际开发中,尽量使用现有的线程安全对象来管理类的状态

2.3加锁机制

当Servlet中添加了一个状态变量,我们可以通过线程安全的Atomic对象来管理Servlet状态,那试想,如果添加了更多的状态,那么是否只用添加线程安全的变量就可以了?

提出需求,我们希望提升Servlet性能,将最近一次计算的结果缓存起来,当存在两个相同的数值请求质因数分解时,直接使用上一次计算的结果(这并非一种有效的缓存策略,5.6节会给出更好的策略)

要实现这种缓存,我们需要保存两个状态:

  • 最近执行质因数分解的数
  • 分解的结果
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    // 缓存上次要分解的值,AtomicReference时一种替代对象引用的原子操作类,通过泛型指定引用类型
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    // 缓存上次分解的结果
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

    public void service(ServletRequest servletRequest, ServletResponse servletResponse) {
        BigInteger i = extractFromRequest(servletRequest);
        if (i.equals(lastNumber.get())) {
            encodeIntoResponse(servletResponse, lastFactors.get());
        } else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(servletResponse, factors);
        }
    }
}

虽然我们已经使用了原子类来保证数字增减的原子性,但是对于我们所定义的这一套业务逻辑,仍然存在竞态条件,因为整体的操作并不是原子的

UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积等于lastNumber的数值,而当多线程同时访问时,可能存在一个线程更改了lastFactors的值成功,而另一个线程更改lastNumber成功的情况,最后出现了这个不变性条件被破坏的情况,同时也有可能A线程获取lastnumber值过程中其他线程修改了lastNumber与lastFactors

当不变性条件涉及多个变量的时候,各个变量之间并不是独立的,因此对他们的修改也需要是原子的

我们需要一个机制来限制这种语句的原子操作

2.3.1内置锁——synchronized

同步代码块分为两部分,一个作为锁对象的引用,一个作为这个锁保护的代码块

synchronized (lock) {
    // 同步代码块
}

其中每一个Java对象都可以作为锁,这些锁被称为内置锁,同时synchronized也可以修饰方法体,实例方法体的锁对象是对象本身,静态方法体的锁对象是该类的Class对象

Java中的内置锁是互斥的,也就是说最多只能有一个线程持有这种锁

当线程A想要获得线程B的互斥锁,必须等待线程B释放锁,,如果B因为某种原因一直不释放,A将一直等下去,这就是死锁

当我们想要运用这种同步机制确保上述案例的线程安全性时就变得简单

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
    // 这里的 @GuardedBy 指的是被内置锁 synchronized 对象保护
    @GuardedBy("this")
    private BigInteger lastNumber;
    @GuardedBy("this")
    private BigInteger[] lastFactors;
		
    public synchronized void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber)) {
            encodeIntoResponse(resp, lastFactors);
        } else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }
}

我们整个的给service方法加上内置锁,不过这样的方式虽然能正确地缓存最新地结果,但并发性却非常糟糕,后续我们会优化其并发性

2.3.2重入

当某个线程请求一个由其他线程持有地锁地时候,发出请求地线程会被阻塞,等待其他线程释放锁,然而,内置锁时可重入的,因此某个线程试图获得一个自己持有的锁的请求就会成功

可重入意味着获取内置锁的粒度是线程而不是调用

可重入锁的一种实现方法:为每一个锁关联一个计数器并记录当前所有者的线程(Java的对象头MarkWord),当计数器为0时,表明这个锁目前没有被任何线程持有;当一个线程请求获得一个未被持有的锁时,MarkWord会记录当前申请的线程ID,并将计数器+1,如果同一个线程再次请求这个锁,计数器的值会递增;而线程退出同步代码块,计数器会相应递减,直到当前线程彻底释放了这个锁,也就是计数器值为0,因此一个线程申请了缩少次锁就应该释放多少次

public class Widget {
    public synchronized void doSomething() {
        //...
    }
}

public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
        System.out.println(toString() + ": calling doSomething";
        super.doSomething();
    }
}

在上述代码中,我们在本线程已经获取锁的情况下,调用父类的doSomething有需要获取锁,如果锁不能重入,那么将进入死锁的状态

2.4 活跃与性能

在2.3的unsafeCachingFactorizer中,我们运用内置锁的方式保障了线程安全,但却极大的背离了我们涉及缓存的初衷——提升性能,因为本应该是并发量大的Servlet网络应用,因为我们给service方法整体添加了内部锁,导致每次只能处理一个请求,其他请求只能等待,同时对于有多核处理器的CPU,即使当前负载很高,其他CPU内核也可能出于闲置的状态,这样极大的浪费了资源、影响了性能

因此,在开发中,我们需要在保证线程安全的情况下尽可能地缩小同步代码块地大小,对于service方法,我们也许并不用同步整个方法

@ThreadSafe
public class CachedFactorizer extends GenericServlet implements Servlet {
    @GuardedBy("this")
    private BigInteger lastNumber;
    @GuardedBy("this")
    private BigInteger[] lastFactors;

    @GuardedBy("this")
    private long hits; // 访问计数器

    @GuardedBy("this")
    private long cacheHits; // 缓存命中计数器

    // 获取访问计数被锁保护
    public synchronized long getHits() {
        return hits;
    }

    // 缓存命中率
    public synchronized double getCacheHitRation() {
        return (double) cacheHits / hits;
    }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this) { // 当前对象作为锁,内置锁。
            ++hits;
            if (i.equals(lastNumber)) {
                factors = lastFactors.clone();
            }
        }

        // 下面这部分不需要被锁保护
        if (factors == null) {
          	// 因数分解操作,这里假设是一个耗时时间长的操作,在进行长耗时/ I/O 阻塞操作之前,先释放锁。
            factors = factor(i);
            synchronized (this) {
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
}

在上述代码中,我们添加了计算缓存命中率地功能,并且我们尽可能地缩小了同步代码块的大小,分成了两个部分,一部分用于判断当前是否可以直接返回,一部分负责原子的更新缓存;其中像factors这样的局部变量就存储在各个线程的栈帧中,并不是共享的状态,因此不存在线程安全问题

一个需要切记的要点,对于可能执行时间较长的计算或者是IO操作这种无法快速完成的操作,一定不能持有锁

同时,由于我们已经使用了同步代码块来构建原子操作,那么我们就可以放弃AtomicXxx类的使用,同时使用两种不同的同步机制不仅会带来混乱,也不会在性能和安全性上增加任何好处

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值