首页 > 编程语言 > 详细

Java并发学习系列(一)--线程安全

时间:2020-06-03 09:22:33      阅读:51      评论:0      收藏:0      [点我收藏+]

Java并发学习系列(一)--线程安全

Very often, thread-safety requirements stem not from a decision to use threads directly but from a decision to use a facility like the Servlets framework.

从现在起开始java并发的学习,那么为什么要学习java并发呢?是不是只有高级程序员或是专门钻研并发的程序员才需要学java并发呢?上面的引用很好地回答了这个问题,大意为线程安全的需求并不是直接来自于程序员要直接写一个多线程的程序,而是程序员为了开发的便捷,使用现有的某个框架。框架运行起来时,要调用程序员自己写的组件,有些框架要求这些组件是线程安全的。

1. 无状态的servlet

以Servlet为例,以下举的例子要完成的任务是从客户端接受一个大整数,服务端将该大整数因式分解并返回结果。

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

StatelessFactorizer没有来自其他类的字段,它甚至都没有字段,所有计算中的暂时状态都存储在局部变量中,而局部变量存在于虚拟机栈上。由java的运行时内存区域可知,每个线程虚拟机都会为其单独分配一个虚拟机栈(还有程序计数器),所以每个线程操作的都是属于自己的单独的对象。

Stateless objects are always thread-safe.

无状态对象总是线程安全的

2. 原子性

//类名就可看出它不是线程安全的
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;
        encodeIntoResponse(resp, factors);
    }
} 

++count在源码级别似乎是原子操作,但在字节码层面至少由3个指令完成:

  1. count变量当前值
  2. 当前值加1
  3. 结果写回

在考虑并发问题时,思维要从源码的层次跳脱出来,源码的一条语句往往是由一组字节码指令完成。

2.1 竟态条件

当计算的正确性依赖于多条线程特定的交叉执行的顺序时,竟态条件就发生了。

2.2 复合动作

当自增动作具有原子性时,竟态条件就消失了。count变量替换为JUC包的AtomicLong类型时,可以保证其上的操作是原子的。

public class CountingFactorizer implements 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);
    }
}

3. 锁

假设为了提高Servlet的性能,要加一个缓存,缓存上一次请求的大整数以及因式分解的结果,方便接下来的请求要求分解相同的大整数时,立马作出响应。基于上一次引入原子类的成功经验,再次引入两个原子引用类AtomicReference的实例存放相应的状态。

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.get()))
            encodeIntoResponse(resp, lastFactors.get() );
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

遗憾的是,上面的代码是非线程安全的,尽管两个原子引用类的实例单独来看是线程安全的,这其实跟整体代码的逻辑有关系。

回想我们想要完成的任务是什么?将大整数因式分解,也就是说两个原子引用类实例之间是有关联的,具体说来,就是lastNumber等于lastFactors中所有整数的乘积。专业术语来表达的话,就是存在一个不变性约束。线程安全的充分条件是该不变性始终保持。

存在这样的可能性:当线程A判断当前请求的大整数与上次大整数相等后,返回上次因式分解结果作为响应之前之间存在一个窗口,线程B有可能更新了这两个状态变量,使得线程A的判断不再为真,那么继续往下执行的结果必定是错误的,因为此时缓存的因式分解结果是另一个不同的大整数的。

还有一种可能性:由于设置两个原子引用变量的操作不是原子的,有可能线程A在设置了lastNumber后,设置lastFactors前被调度,失去了CPU时间片,此时不变性约束被破坏了。线程B开始执行,判断条件为真,返回缓存中的因式分解结果后完成退出,这样线程B就返回了错误的结果。

To preserve state consistency, update related state variables in a single atomic operation.

为了保护状态一致性,在一个原子操作中更新相关联的状态变量。

3.1 内部锁(Intrinsic Locks)

public class SynchronizedFactorizer implements Servlet {
    private BigInteger lastNumber;
    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);
        }
    }
}

对整个方法加锁影响并发性能,所以实际中并不鼓励这样做。

3.2 可重入

可重入指的是当持有锁的线程试图再次获取已持有的锁时可以获取成功,不然会出现自己锁死自己的情形。

可重入意味着所得获取是以线程为单位的,而不是以调用为单位的。

4. 重构

public class CachedFactorizer extends GenericServlet implements Servlet {
    private BigInteger lastNumber;
    private BigInteger[] lastFactors;
    private long hits;
    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 (factors == null) {
            factors = factor(i);
            synchronized (this) {
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn‘t really factor
        return new BigInteger[]{i};
    }
}

正如前面所说,互斥锁加在整个方法上会降低并发性能。在以上重构的代码中,减小了锁的粒度,方法中加入了两个同步代码块,维护状态的变量也变为普通类型,不再是JUC包中的并发类型(既然用了加锁的方式实现同步,再用JUC中的类型就多余了)。

需要注意的是,并不是把需要同步的代码分得越碎越好,比如每一条语句都用synchronized包围,因为加锁、释放锁也是很费时的操作。之所以要把以上的代码要分为两个代码块,是因为factor方法是很费时的操作,相比加锁、释放锁代价更为高昂,所以在factor方法执行前释放锁是有收益的。

令我困惑的是,如果factor方法每次返回一个新建的数组对象,factors = lastFactors.clone()lastFactors = factors.clone()是出于什么考虑?如果替换为仅仅是个变量赋值语句呢?

Java并发学习系列(一)--线程安全

原文:https://www.cnblogs.com/zkwen/p/13034836.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!