Java并发编程实战读书笔记——第二章线程安全性

提示(点击跳转)

2.1 什么是线程安全?

2.2 原子性

2.2.1 竞态条件
2.2.2 延迟初始化中的竟态条件
2.2.3 复合操作

2.3 加锁机制

2.3.1 内置锁
2.3.2 重入

2.4用锁来保护状态

2.5 活跃性与性能


Java中主要的同步机制有关键字synchronized,volatile变量显示锁原子变量

2.1 什么是线程安全?

  • 线程安全就是当多个线程访问某个类时,这个类始终都能表现出正确的行为。
  • 下面代码展示了无状态对象(类),应为里面没有可变的变量或状态。所以一定是线程安全的。
/**
 *因为访问此方法,不会影响另一个线程对其访问计算的结果,所以线程安全。
 * 也就是多个线程去访问此方法结果是不互相干扰的,没有共享的变量。
 * 它不包含共享的变量和其他域中的引用,计算过程中临时状态仅存在线程栈上的局部变量中。
 */
@ThreadSafe
public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);//获取数
        BigInteger[] factors = factor(i);//对servlet中数进行因数分解
        encodeIntResponse(resp,factors);//将因数进行返回
    }
}

2.2 原子性

所谓的原子性,要不都一次执行,别的线程不能干扰,要不都不执行。

/**
 * 统计已处理请求的个数
 * 这个为线程不安全。假设count的初始值为5,当来多个线程去执行service的时候,都将其值改为了6,这就造成了
 * 严重错误。
 */
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    public long getCount(){
        return count;
    }
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractRequest(req);
        BigInteger[] factors = factors(i);
        ++count;
        encodeIntResponse(resp,factors);
    }
}

上面代码分析原因:
在这个类中有了共享变量count,同时++count的操作不是原子性的,有三个独立的操作在进行“读入-修改-写入”的操作序列,并且最终结果依赖于之前count的状态。n(n>2)个线程到来都进行读入count之前状态,然后进行++操作,最终写入只是将count+1了,实际应该是count+n。

2.2.1 竞态条件
  • 当某个计算的正确性取决于多个线程的交替执行时序时,就会发现竞态条件。
  • 常见的竞态条件类型有:“先检查后执行”,“读入-修改-写入”。本质是基于可能失效的观察结果来做出后序的判断或计算,因为此时在检查到执行的过程中可能会有别的线程来改变你原来的观察结果。
2.2.2 延迟初始化中的竟态条件

是对“先检查后执行给了一个示例” 。拿未加锁的懒汉式单例做例子。

/**
 * 因为if-else的存在有了“先检查后执行”,存在竟态条件。会造成线程不安全。
 */
@NotThreadSafe
public class LazyInitRace {
    //私有属性
    private LazyInitRace instance =null;
    //私有构造器
    private LazyInitRace(){ };
    //暴露方法
    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}
2.2.3 复合操作
  • 前面的“先检查后执行”,“读入-修改-写入”被称为复合操作。
  • 在2.2UnsafeCountingFactorizer和2.2.2LazyInitRace对其“读入-修改-写入”和“先检查后执行”的操作需要包含一组以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改报作完成之前或之后达取和修改状态,而不是在修改状态的过程中。
  • 在这里使用原子变量类使得复合操作变为原子操作。
/**
 *使用了java中java.util.concurrent.atomic包中的一些原子变量类
 */
@ThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0);//使用原子变量类,实现数值上的原子状态装换
    public long getCount(){
        return count.get();
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractRequest(req);
        BigInteger[] factors = factors(i);
        count.incrementAndGet();//原子递增当前值
        encodeIntResponse(resp,factors);
    }
}

对原子变量类的解释:
 在 javautil. concurrentatomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet也是线程安全的。

 AtomicLong的底层实际使用了关键字volatile.

public class AtomicLong extends Number implements java.io.Serializable {
    private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(java.util.concurrent.atomic.AtomicLong.class, "value");
    private volatile long value;

    public final long get() {
        return value;
    }
    //原子递增当前值
    public final long incrementAndGet() {
        return U.getAndAddLong(this, VALUE, 1L) + 1L;
    }
}

2.3 加锁机制

在无状态中添加一个状态由线程安全的对象来管理,这个类是线程安全的(2.2.3中的CountingFactorizer),但是存在多个状态变量就算每个都由变为原子的,该类依然不安全。

/**
 * 缓存上一次的数和结果,如果下一次来的数相同,则直接返回。
 * 下面将变量都包装成为了线程安全,但是其整体类却还是线程不安全的,因为变量和变量之间有依赖顺序
 * 其实还存在竟态条件。需要将方法的操作也变为原子的(或加锁)
 */
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    //AtomicReference是替代对象引用的线程安全类
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();//@1
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();//@2
    public void service(ServletRequest req, ServletResponse resp){//@3
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){//@3.1
            encodeIntoResponse(resp,lastFactors.get());
        }else {//@3.2
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,lastFactors.get());
        }
    }
}

代码思考

  • @1和@2行变量为了防止出现“读入-修改-写入”的问题,将其用原子变量类包装。@3行的方法依然会造成线程不安全。假如一个线程进入@3.2行执行将lastNumber修改,此时另一个线程进入@3行,执行@3.1那么获取到的lastFactirs就是上一次未改变的。因此此方法必须是原子操作或是加锁。
  • 如果要保证状态的一致性,必须将多个变量的所有状态更改变成原子性,一次性修改完成。
2.3.1 内置锁
  • Java提供了一种内置的锁机制来支持原子性:同步代码块。
    同步代码块锁的是方法调用所在的对象,静态的synchronized方法以Class对象作为锁。
  • 每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁( Intrinsic Lock)或监视器锁( Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。获得内锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
  • Java的内置锁相当于一种互斥体(或互斥锁,由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
/**
 * 使用synchronized来给方法加锁
 */
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
    //AtomicReference是替代对象引用的线程安全类
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
    public synchronized void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){
            encodeIntoResponse(resp,lastFactors.get());
        }else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,lastFactors.get());
        }
    }
}

代码思考

  • 当一个线程来执行service(),只有获得当前对象的内置锁,才能去执行此service()方法,并且去调用当前对象的两个属性lastNumber和lastFactors,改变其状态。
  • 在程序中使用了关键字synchronized来修饰service方法,因此在同一时刻只有一个线程可以执行service方法。(因为一个线程获得当前对象的内置锁,其他的线程要去等待)现在的SynchronizedFactorizer是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解Servlet,服务的响应性非常低。
2.3.2 重入
  • 当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。内置锁是可重入的,也就是某个线程试图获取一个已经由它自己持有的锁,那么这个请求就会成功。“重人”意味节获取锁的操作的粒度是线程而不是“调用”。
  • 重入的原理:
    为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
/**
 *如果内置锁不是可重入的话,这段代码就会发生死锁。
 */
public class Widget {
    public synchronized void doSomething(){}
}
class LoggingWidget extends Widget{
    public synchronized void dosomething(){
        System.out.println(toString()+"calling doSomehing");
        super.doSomething();
    }
}

代码思考:
  子类重写了父类的 synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于 Widget和 LoggingWidget中 doSomething方法都是 synchronized方法,因此每个 dosomething方法在执行前都会获取Widget的锁,然而,如果内置锁不是可重入的,那么在调用 super doSomething时将无法获得 widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。
问题:
  1.线程获取的锁是谁的锁?假设new了一个LoggingWidget类型的对象A,是获取了当前对象A的锁吗?(自己理解Java中锁基于对象的,每个对象都有自己的锁。)
  2.为神魔书中说是因此每个dosomething方法在执行前都会获取Widget的锁。其子类不应该获取LoggingWidget的锁吗?

2.4 用锁来保护状态

  • 在2.3.1的程序中lastNumber和lastFactors这两个变量都是由Servlet的当前对象的内置锁 来保护的。
  • 之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。
  • 并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

2.5 活跃性与性能

  • 在2.3UnsafeCachingFactorizer代码中,我们通过在因数分解Servlet中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用SynchronizedFactorizer中的同步方式,那么代码的执行性能将非常糟糕。2.3.1中SynchronizedFactorizer中直接对整个方法进行synchronized,这样每次只能有一个线程可以执行,背离了Servlet的设计初衷,其实需要同时处理多个请求的。
@ThreadSafe
public class CachedFactorizer implements Servlet {
    private BigInteger lastNumber;//缓存上一次的数
    private BigInteger[] lastFactors;//缓存上一次数lastNumber的因数
    private long hits;//执行请求的个数
    private long cachehits;//所有请求中直接和lastNumber相同,使用lastFactors的请求的个数
    //获取hits的个数
    public synchronized long getHits(){
        return hits;
    }
    //获取命中缓存的请求个数
    public synchronized double getCachehits(){
        return (double)cachehits/(double)hits;
    }
    //Servlet进行服务
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);//获取到计算的数
        BigInteger[] factors = null;//存储i的因数,每一个请求到来都会刷新为null
        //对代码块进行加锁
        /*1.++hits是“读入-修改-写入”的符合操作,使得其变为原子操作
        * 2.这里的if语句存在了一个“先检查后执行”的符合操作也是线程不安全的,所以加锁,也是其变为了原子操作
        * 提示:this标识只有获得当前对象的锁才可以进入执行
        */
        synchronized (this){
            ++hits;
            if (i.equals(lastNumber)) {
                ++cachehits;
                factors = lastFactors.clone();
            }
        }
        if(factors==null){
            factors = factor(i);//求i的因数
            /**
             * 如果本次没有命中缓存,则将lastNumber和lastFactors刷新
             * 对其进行加锁,使其操作原子化
             */
            synchronized (this){
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntResponse(req,resp);
    }
}
  • 在CachedFactorizer中不再使用AtomicLong类型的命中计数器,而是使用了一个 long类型的变量。当然也可以使用AtomicLong类型,对在单个变量上实现原子操作来说,=原子变量是很有用的,但由于我们已经使用了同步代码块来、构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。

使用锁时应该考虑的:

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

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

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


点击回到顶部

上一篇:JDOJ 1140: 完数


下一篇:java – 查找600851475143中最大的素数?