线程安全

通过这篇博客你能学到什么:

线程安全-LMLPHP


编写线程安全的代码,本质上就管理状态的访问,而且通常是共享的、可变的状态.

状态:可以理解为对象的成员变量.

共享: 是指一个变量可以被多个线程访问

可变: 是指变量的值在生命周期内可以改变.

保证线程安全就是要在不可控制的并发访问中保护数据.

如果对象在多线程环境下无法保证线程安全,就会导致脏数据和其他不可预期的后果


在多线程编程中有一个原则:
无论何时,只要有对于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问**

Java中使用synchronized(同步)来确保线程安全.在synchronized(同步)块中的代码,可以保证在多线程环境下的原子性可见性.

不要忽略同步的重要性,如果程序中忽略了必要的同步,可能看上去是可以运行,但是它仍然存在隐患,随时都可能崩溃.

在没有正确同步的情况下,如果多线程访问了同一变量(并且有线程会修改变量,如果是只读,它还是线程安全的),你的程序就存在隐患,有三种方法修复它:
1. 不要跨线程共享变量
2. 使状态变为不可变的
3. 在任何访问状态变量的时候使用同步

虽然可以用上述三类方法进行修改,但是会很麻烦、困难,所以一开始就将一个类设计成是线程安全的,比在后期重新修复它更容易

封装可以帮助你构建线程安全你的类,访问特定变量(状态)的代码越少,越容易确保使用恰当的同步,也越容易推断出访问一个变量所需的条件.总之,对程序的状态封装得越好,你的程序就越容易实现线程安全,同时有助于维护者保持这种线程安全性.

设计线程安全的类时,优秀的面向技术--封装、不可变性(final修饰的)以及明确的不变约束(可以理解为if-else)会给你提供诸多的帮助

虽然程序的响应速度很重要,但是正确性才是摆在首位的,你的程序跑的再快,结果是错的也没有任何意义,所以要先保证正确性然后再尝试去优化,这是一个很好的开发原则.


 1 什么是线程安全性


一个类是线程安全的,是指在被多个线程访问时,类可以持续进行正确的行为.

对于线程安全类的实例(对象)进行顺序或并发的一系列操作,都不会导致实例处于无效状态.

线程安全的类封装了任何必要的同步,因此客户不需要自己提供.

 2 一个无状态的(stateless)的servlet

public class StatelessServlet implements Servlet {

@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = factor(i);
encodeIntoResponse(servletResponse,factors);
}

}

 

我们自定义的StatelessServlet是无状态对象(没有成员,变量保存数据),在方法体内声明的变量i和factors是本地变量,只有进入到这个方法的执行线程才能访问,变量在其他线程中不是共享的,线程访问无状态对象的方法,不会影响到其他线程访问该对象时的正确性,所以无状态对象是线程安全的.

这里有重要的概念要记好:无状态(成员变量)对象永远是线程安全的

 

3 原子性


在无状态对象中,加入一个状态元素,用来计数,在每次访问对象的方法时执行行自增操作.

public class StatelessServlet implements Servlet {
private long count = 0;

@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = factor(i);
count++;
encodeIntoResponse(servletResponse,factors);
}

在单线程的环境下运行很perfect,但是在多线程环境下它并不是线程安全的.为什么呢? 因为count++;并不是原子操作,它是由"读-改-写"三个操作组成的,读取count的值,+1,写入count的值,我们来想象一下,有两个线程同一时刻都执行到count++这一行,同时读取到一个数字比如9,都加1,都写入10,嗯 平白无故少了一计数.

现在我们明白了为什么自增操作不是线程安全的,现在我们来引入一个名词竞争条件.

 

4 竞争条件

**当计算的正确性依赖于运行时相关的时序或者多线程的交替时,会产生竞争条件**.

我对竞争条件的理解就是,**多个线程同时访问一段代码,因为顺序的问题,可能导致结果不正确,这就是竞争条件**.

除了上面的自增,还有一种常见的竞争条件--"检查再运行".

废话不多说,上代码.

/**
 * @author liuboren
 * @Title: RaceCondition
 * @ProjectName multithreading
 * @Description: TODO
 * @date 2018/10/7 15:54
 */
public class RaceCondition {

    private boolean state = false;

    public void test(){
        if (state){
            //做一些事
        }else{
            // 做另外一些事
        }
    }

    public void changeState(){
        if(state == false){
            state = true;
        }else{
            state = false;
        }
    }
}

 

代码很简单,test()方法会根据对象的state的状态执行一些操作,如果state是true就做一些操作,如果是false执行另外一些操作,在多线程条件下,线程A刚刚执行test()方法的,线程B可能已经改变了状态值,但其改变后的结果可能对A线程不可见,也就是说线程A使用的是过期值.这可能导致结果的错误.

 

5. 示例: 惰性初始化中的竞争条件

这个例子好,多线程环境下的单例模式.

/**
 * @author liuboren
 * @Title: Singleton
 * @ProjectName multithreading
 * @Description: TODO
 * @date 2018/10/7 16:29
 */
public class Singleton {
    private Singleton singleton;

    private Singleton() {
    }

    public Singleton getSingleton(){
        if(singleton ==null){
               singleton = new Singleton();
                      }
        return singleton;
    }

}

看这个例子,我们把构造方法声明为private的这样就只能通过getSingleton()来获得这个对象的实例了,先判断这个对象是否被实例化了,如果等于null,那就实例化并返回,看似很完美,在单线程环境下确实可以正常运行,但是在多线程环境下,有可能两个线程同时走到new对象这一行,这样就实例化了两个对象,这可能不是我们要的结果,我们来小小修改一下

/**
 * @author liuboren
 * @Title: Singleton
 * @ProjectName multithreading
 * @Description: TODO
 * @date 2018/10/7 16:29
 */
public class Singleton {
    private Singleton singleton;

    private Singleton() {
    }

    public Singleton getSingleton(){
        if(singleton ==null){
            synchronized (this) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

}


限于篇幅,这里直接改了一个完美版的,之所以不在方法声明 synchronized是为了减少同步快,实现更快的响应.


6 复合操作

为了避免竞争条件,必须阻止其他线程访问我们正在修改的变量,让我们可以确保:当其他线程想要查看或修改一个状态时,必须在我们的线程开始之前或者完成之后,而不能在操作过程中

将之前的自增操作改为原子的执行,可以让它变为线程安全的.使用Synchronized(同步)块,可以让操作变为原子的.

我们也可以使用原子变量类,是之前的代码变为线程安全的.

    private final AtomicLong count = new AtomicLong(0);

    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        BigInteger i = extractFromRequest(servletRequest);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(servletResponse, factors);
    }

 

7 锁

Java提供关键字Synchronized(同步)块,来保证线程安全,可以在多线程条件下保证可见性和原子性.

可见性: 一个线程修改完对象的状态后,对其他线程可见.

原子性: 可以把复合操作转换为不可再分的原子操作.一个线程执行完原子操作其它线程才能执行同样的原子操作.


让我们看看另一个关于线程安全的结论:当一个不变约束涉及多个变量时,变量间不是彼此独立的:某个变量的值会制约其他几个变量的值.因此,更新一个变量的时候,要在同一原子操作中更新其他几个.

觉得过于抽象?我们来看看实际的代码

/**
 * @author liuboren
 * @Title: StatelessServlet
 * @ProjectName multithreading
 * @Description: TODO
 * @date 2018/10/7 15:04
 */
public class StatelessServlet implements Servlet {
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<>();

    private final AtomicReference<BigInteger[]> lastFactors
            = new AtomicReference<>();


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

简单说明一下,AtomicLong是Long和Integer的线程安全holder类,AtommicReference是对象引用的线程安全holder类. 可以保证他们可以原子的set和get.

我们看一下代码,根据lastNumber.get()的结果取返回lastFactors.get()的结果,这里存在竞争条件.因为很有可能线程A执行完lastNumber.set()且还没有执行lastFactors.set()的时候,另一个线程重新调用这个方法进行条件判断,lastNumber.get()取到了最新值,通过判断进行响应,但这时响应的lastFactors.get()却是过期值!!!!


FBI WARNING: 为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量.

 

8 内部锁

每个对象都有一个内部锁,执行线程进入synchronized快之前获得锁;而无论通过正常途径退出,还是从块中抛出异常,线程在放弃对synchronized块的控制时自动释放锁.获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法.

内部锁是互斥锁,意味着至多只有一个线程可以拥有锁,当线程A尝试请求一个被线程B占有的锁时,线程A必须等待或者阻塞,直到B释放它,如果B永远不释放锁,A将永远等待下去

内部锁对提高线程的安全性来说很好,很perfect,but但是,在上锁的时间段其他线程被阻塞了,这会带来糟糕的响应性.

我们再来看之前的单例模式

/**
 * @author liuboren
 * @Title: Singleton
 * @ProjectName multithreading
 * @Description: TODO
 * @date 2018/10/7 16:29
 */
public class Singleton {
    private Singleton singleton;

    private Singleton() {
    }

 /*   public Singleton getSingleton(){
        if(singleton ==null){
            synchronized (this) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }*/

    public synchronized Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

 

在方法上加synchronized可以保证线程安全,但是响应性不好,上面注解掉的是之前优化后的方法.


9 用锁来保护状态

下面列举了一些需要加锁的情况.

1. 操作共享状态的复合操作必须是原子的,以避免竞争条件.例如自增和惰性初始化.

2. 并不是所有数据都需要锁的保护---只有那些被多个线程访问的可变数据.

3. 对于每一个涉及多个变量的不变约束,需要同一个锁保护其所有变量


10 活跃度与性能
虽然在方法上声明 synchronized可以获得线程安全性,但是响应性变得很感人.

限制并发调用数量的,并非可用的处理器资源,而恰恰是应用程序自身的结构----我们把这种运行方式描述为弱并发的一种表现.

通过缩小synchronized块的范围来维护线程安全性,可以很容易提升代码的并发性,但是不应该把synchronized块设置的过小,而且一些很耗时的操作(例如I/O操作)不应该放在同步块中(容易引发死锁)

决定synchronized块的大小需要权衡各种设计要求,包括安全性、简单性和性能,其中安全性是绝对不能妥协的,而简单性和性能又是互相影响的(将整个方法声明为synchronized很简单,但是性能不太好,将同步块的代码缩小,可能很麻烦,但是性能变好了)

原则:
通常简单性与性能之间是相互牵制的,实现一个同步策略时,不要过早地为了性能而牺牲简单性(这是对安全性潜在的妥协).

最后,使用锁的时候,一些耗时非常长的操作,不要放在锁里面,因为线程长时间的占有锁,就会引起活跃度(死锁)与性能风险的问题.

嗯,终于写完了.以上是博主<<Java并发编程实战>>的学习笔记,如果对您有帮助的话,请点下推荐,谢谢.

   

10-07 20:34