【Java并发编程实战-阅读笔记】01-线程安全性

1.1 并发简史

        计算机加入操作系统的实现多进程运行的好处:提高资源利用率;多用户、程序的公平性;便利性。
        线程:轻量级进程。大多数现代的操作系统中,都是以线程为基本单位进行调度。
        如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量,并且在同一个堆上分配对象,这就需要实现一种比在进程共享数据粒度更细的数据共享机制。

1.2 线程的优势

        发挥多处理器的强大能力。由于基本的调度单位是线程,因此如果程序里只有一个线程,那么最多只能在一个cpu上执行。对于双核cpu,那么该程序只能使用50%的cpu资源。此外,多线程还能提升系统资源(比如I/O)的吞吐率,程序可以在I/O阻塞期间继续运行。
        建模的简单性。通过使用线程,可以将复杂且异步的工作流进一步分解为一组简单的而且同步的工作流,每个工作流在一个单独的县城中运行,并且在特定的同步位置进行交互。
        异步事件的简化处理。程序在接收多个来自远程客户端的socket连接请求时,可以为每个连接都分配线程并且使用同步I/O,会降低此类程序的开发难度。
        响应更加灵敏的用户界面。

1.3 线程的风险

        一、安全性问题。(确保永远不发生坏事)

        多线程交替操作会导致不可预料的结果。
@NotThreadSafe
public class UnsafeSequence(){
    private int value;
    public int getNext(){ //解决方式是加synchronized
        return value++;
    }
}
如果有两个线程同时调用getNext的时候,会出现如下问题:
(1)A-从内存获取value=9; B-
(2)A-cpu执行9+1=10; B-从内存获取value=9;
(3)A-将10写入内存value; B-cpu执行9+1=10;
(4)A-; B-将10写入内存value;
最终,由于两个线程交替执行,本应该是11,结果内存中的值为10。
        如果没有同步,那么无论是编译器、硬件还是运行的时候,都可以随意安排操作的执行时间和顺序,比如,寄存器或CPU对变量进行缓存,这个底层操作,线程是不可见的。

        二、活跃性问题。(确保线程最终会执行)

        如果线程A在等待线程B释放其持有的资源,而线程B永远不释放资源,那么A就永远的等待下去。

        三、性能问题。

        多线程程序往往会带来一些性能问题。
(1) 频繁的上下文线程切换操作。CPU会耗时在线程调度上;
(2) 同步会抑制编译器的优化机制,使得内存缓存区的数据无效,增大共享内存总线的同步流量。
 
 

        要编写线程安全的代码,核心在于对“状态访问操作”进行管理,特别是共享的和可变的状态访问。
        对象的状态:存储在状态变量中的数据。对象的状态还可能包括其他的依赖对象。比如HashMap实例的状态,不仅在HashMap对象本身,还存储在Map.Entry对象中(key/value)。
        一个对象是否需要线程安全,取决于其是否要被多线程访问。如果需要对象线程安全,则需要同步机制去协同对“对象可变状态”的访问。
        如果在多线程下要保证对象的线程安全,可以:不共享。修改变量为不可变。采用同步机制。
        

2.1 什么是线程安全性

        线程安全性核心是正确性。
        正确性就是,某个类的行为,与他的规范完全一致。
        线程安全性:当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,而且主代码中不需要任何额外的同步或者协同,这个类始终都能表现出正确的行为。就称这个类是线程安全的。
        无状态对象:不包含任何域(成员属性),也不包含任何其他类的引用。计算过程中的临时状态仅仅存在于线程栈上的局部变量中,而且只能由正在执行的线程访问。   
        无状态对象一定是线程安全的。

2.2 原子性

        首先看下面的例子:
@NotThreadSafe
public class UnsafeCounting implements Servlet {
    private long count = 0;
    public long getCount(){return count;}
    public voide service(ServletRequest req,ServletResponse resp){
        //···业务操作,req,resp
        ++count;
        //···业务操作,req,resp
    }
}
上面的例子不是线程安全的。虽然++count是一种紧凑语法,但是这个操作不是原子的,它会分割成3个操作:
“读取count,自增1,写回count”,即读取、修改、写入的操作序列。其结果状态是依赖于之前的状态。在并发编程中,由于不恰当的执行顺序导致了不一致的结果,称之为“竞态条件”。
        当某个计算的正确性取决于多个线程交替执行时序时,就会发生竞态条件。最常见的就是“先检查后执行(Check-Then-Act)”操作。通过一个可能失效的观测结果决定下一步的操作。
        延迟初始化的竞态条件例子。
@NotThreadSafe
public class LazyInitRace{
    private TestObject instance = null;
    public TestObject  getInstance(){
        if(null == instance){
            instance = new TestObject();
        }
        return instance;
    }
}
        上面的代码就包含了竞态条件。
        上面两个例子,都包含了一组“需要以原子方式”执行的操作。
        如果有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B的时候,要么B全部执行完,要么全部不执行B,那么A和B彼此来说是原子的。
        如果count++操作是原子的,那么竞态条件就不会发生。为了保证线程安全性,“先检查后执行”以及“读取-修改-写入”,必须是原子的。
        下面是以某一种方式修复这个问题,让“读取-修改-写入”的符合操作编程原子的。
@ThreadSafe
public class UnsafeCounting implements Servlet {
    private final AtomicLong count = new AtomicLong(0);
    public long getCount(){return count;}
    public voide service(ServletRequest req,ServletResponse resp){
        //···业务操作,req,resp
        count.incrementAndGet();
        //···业务操作,req,resp
    }
}
java.util.concurrent.atomic包中,包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。由于该Servlet的状态,就是这个计数器的状态,而且计数器是线程安全的,所以这个servlet也是线程安全的。

2.3 加锁机制

        当在Servlet中添加了一个状态变量,可以通过线程安全的对象来管理这个Servlet的状态以维护Servlet线程的安全性。如果Servlet中添加了更多的状态呢?
        参考下面的例子,如果有一个Servlet用来做因式分解,如果两个连续相同的请求的话,就把上一次的结果值缓存起来,然后第二次就直接使用第一次的结果。实现这个策略,需要保存两个状态,最近一次执行因数分解的数值,以及分解的结果。这里使用AtomicReference:代替对象引用的线程安全类。
@NotThreadSafe
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.equals(lastNumber)){
            encodeIntoResponse(resp,lastFactors.get());
        } else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,factors);
        }
    }
}
      尽管原子引用本身是线程安全的,但是这个方法并不正确。因为只有保证lastNumber和lastFactors同时在一个原子操作中更新,Servlet才正确。
        要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
        一、内置锁
        上面的两个操作如何组合为一个原子操作呢?通过内置的锁机制:同步代码块。包括两个部分,一个作为锁的对象引用,一个作为由这个锁保护的代码块。
        以synchronized修饰的方法/代码块,如果是对象实例,那么锁就是这个对象(方法调用所在的对象),如果是静态的方法、代码块,则以Class对象为锁。
synchronized (lock){
    //……
}
 每个java对象都可以当成一个实现同步的锁,也成为内置锁、监视锁。进入代码块的时候获取锁,离开代码块的时候释放锁(不管是正常退出还是异常退出)。
        内置锁是一种互斥锁。最多只能有一个线程持有,这个时候另一个线程需要等待或者阻塞。而因为同时只能有一个线程执行内置锁保护的代码块,因此该代码块是原子的。
        下面修改后的例子,虽然线程安全了,但是性能很差,因为同一时刻只能有一个线程在执行service方法。
@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
    
    public synchronized void service(ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber)){
            encodeIntoResponse(resp,lastFactors.get());
        } else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,factors);
        }
    }
}

  二、重入

       重入:如果线程A请求线程B正在持有的锁的时候,线程A会阻塞等待。但是如果说,线程A已经持有了这个锁,然后A可以再次获取这个锁。这个就是重入。
        这个概念说明:获取锁、释放锁的操作粒度是“线程”,而不是调用。
        一般来说,重入的实现方式是,给每个锁加一个计数器。同一个线程请求一个没有被持有的锁的时候,每次请求+1。每次退出-1。
        重入提升了加锁行为的封装性。比如下面的例子:
 
public class Widget(){
    public synchronized void doSomething(){
    }
}

public class LoggingWidget extends Widget(){
    public synchronized void doSomething(){
        System.out.println(new Date() + " logged in.");
        super.doSomething();
    }
}
        上面的情况,如果线程A访问了LoggingWidget中的doSomething方法,先获取了锁,进入子类方法,然后子类又调用了父类的方法。如果锁如果不能重入,在调用super.doSomething()的时候,线程A需要再次获取锁,但是有获取不到锁,因为这个锁已经被自己持有了。这个情况就发生死锁了。重入则可以避免死锁的发生。

2.4 用锁来保护状态

        通过锁,可以保护代码块始终以串行的方式访问(不会出现并发执行同一段代码,都需要按先后顺序)。通过锁,可以把符合的操作封装成一个原子操作。
        然而,如果在很多地方都需要访问某个变量,那么在访问变量的所有位置,都需要加一个锁。
        通过synchronized的时候,用的是对象/类的内置锁,这个内置锁和变量(需要保护的)是没有直接关系的。因为,就算你拿到这个内置锁,你也不能控制别的线程去访问你的变量。
        如果想要保证对象内的属性都是安全的话,那么只能将这个对象的所有可变状态全部封装在内部,并且对所有访问这些状态的代码进行同步,即通过内置锁全部保护起来。问题是,如果后面人添加了一些方法,却忘记加锁了,就会导致对象不安全。因此,这种方式很难维护。
        对于包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个内置锁保护。
        注意,虽然synchronized可以保证方法称为同步方法,但是如果是多个同步方法的组合操作,那么这个组合操作仍然不是原子性的,还需要额外的加锁机制。而且过多使用synchronized会导致性能问题和活跃性问题。
 

2.5 活跃性与性能

        不良并发应用程序:上面的Servlet例子,如果在service方法上添加了synchronized,那么就违背了Servlet框架的初衷。因为这个方法只能同时有一个线程处理。而且如果service耗时很高的时候,其他的请求只能永远等待,尽管此刻,CPU可能很空闲。
        下面的代码则可以较好的解决这个问题:       
@ThreadSafe
public class CachedFactorizer {
    @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 getCacheHitRatio() {
        return (double) cacheHits / (double) hits;
    }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this){
            ++hits;
            if(i.equals(lastNumber)){
                ++cacheHits;
                factors = lastFactors.clone();
            }
        }
        if(null == factors){
            factors = factor(i);
            synchronized (this){
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp,factors);
    }
}
       上面的代码有几点需要注意的:
(1)在统计访问量的时候,有两个参数,一个hit,是请求数,另一个是cacheHit,是访问了缓存的请求统计数。
(2)之前的版本,count采用了AtomicLong类型,但是这里因为已经用了另一种同步方式去构造原子操作,而两种同步机制会造成混乱,或者性能、安全性上的问题,因此只保留了一个。
(3)这个版本的代码在简单性和并发性上进行了平衡。如果分解的过细,比如将++hits也放到一个synchronized块中,也不好,因为频繁获取、释放锁也会消耗资源。
(4)需要耗时的“factors = factor(i);”并没有加入到同步块里面,这点很重要,这样既保证安全性,也不会影响并发性。
        简单性(一个synchronized包裹最简单)和性能(同步块里的耗时越少越好,也就是代码越短越好)是相互制约的。
        执行时间较长的计算或者IO等可能无法快速完成的操作,千万不要持有锁。
 
总结
1、线程的优势:发挥多cpu的能力,建模简单,异步事件简化,用户响应灵敏。
2、风险:安全性(可能会发生坏事),活跃性(某个线程永远不执行),性能(频繁切换线程)。
3、线程安全性就是正确性,正确性就是就是某个类的行为和规范(定义的预期)完全一致。
4、某个计算的正确性取决于多个线程交替执行的时序,就会发生竞态条件。
5、常见竞态:“先检查后执行”、“读取-修改-写入”。
6、通过synchronized可以将符合操作构成一个原子操作。
7、synchronized本质上采用了内置锁(又称同步锁、监视锁),对象实例里面,锁就是当前对象,如果是静态方法里面,锁就是Class对象。
8、获取锁、释放锁的操作粒度是线程。重入锁是内部有个计数器。
9、synchronized同步块要考虑简单性和性能。
 
原文地址:https://www.cnblogs.com/shenggang/p/8510440.html