首页 > 代码库 > 线程安全性

线程安全性

多线程程序中,如果控制不好,经常会出现各种的问题,有时候问题在调试或者测试的时候就会暴露出来,最要命的是程序部署一段时间之后才出现各种怪异现象,感到头疼?需要补充Java并发的知识了。该读书笔记系列以《Java并发编程实战》为基础,同时会参考网络上一些其他的资料,和大家一起学习Java并发编程的各个方面。这方面也是笔者比较薄弱的地方,理解不对的地方请留言或者邮件指出,同时也欢迎讨论。邮箱:sunwei.pyw@gmail.com

线程安全性这章,将从如下几个方面入手,描述探讨:

1、什么是对象的状态

2、线程安全

3、无状态和有状态

4、竞态条件

5、加锁机制

内置锁

锁的重入

6、用锁来保护状态

7、小结

本章大部分是比较抽象的概念,不过没关系,我们将列举一些代码来逐一说明,这些概念上的认识,将会对以后理解有很大的帮助。

1、什么是对象的状态

书中指出,从非正式的意义上说,对象的状态是指存储在状态变量(实例或者静态域)中的数据,对象的状态可能包括其他的依赖对象的域,例如某个HashMap的状态不仅存储在HashMap本身,还存储在许多Map.Entry中。在对象的状态中包含了任何可能影响其外部可见行为的数据。我们可以简单地理解为对象的状态就是对象的值和属性,对于简单的类型,就是其值。线程安全的代码,核心就是要对状态访问操作进行管理,特别是“共享的”和“可变的”状态。共享意味着多个线程可以同时访问,而可变的意思就是在其生命周期内,其值可以改变。

如果一个变量处于方法内部,它并不是共享的,因为每个线程执行代码的时候,都会有各自的值存储在线程的局部变量内,其他线程是无法访问的。如果一个类变量被声明成final的,它的属性也没有提供可访问的修改方法,那么它的状态就是不可变的。

2、线程安全

线程安全的核心就是正确性,正确性的含义是,某个类的行为与其规范完全一致。这里的规范可以理解为预期。

当多个线程访问某个类时,不需要添加任何的同步或者协同,这个类始终都能表现出正确的行为和结果,那么就称这个类是线程安全的。

可以这样理解,一个对象是否需要线程安全,取决于它是否被多个线程访问。这里指的是程序访问对象的方式,而不是对象要实现的功能。单线程访问任何状态都是安全的。如果多个线程访问一个未知线程安全的对象,就需要对访问方式进行控制。一个对象是否是线程安全的,指的是对象的实现本身就是线程安全的。这种情况下,不论对线程的访问是否做了控制,这个对象总是线程安全的。

线程安全的程序是否完全由线程安全的类组成?答案是否定的,完全由线程安全类构成的程序不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。

所以线程安全其实就是在多线程环境下,类是否始终可以表现出正确的行为和结果,这个正确性其实是我们业务上的预期。

3、无状态和有状态

Servlet是多线程单实例的,这意味着多个多个请求共享一份Servlet对象,是一个典型的多线程访问的例子。Servlet分为无状态和有状态,分析如下的例子,本例子对书上的例子做了稍微的简化:

public class SafeServlet implements Servlet{

    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException {
        String str1 = req.getParameter("param1");
        String str2 = req.getParameter("param2");
        res.getWriter().write(str1 + str2);
    }
…
}

 

此处省略了其他的一些方法,service方法从请求中获取到两个字符串,返回两个字符串的连接。

这个类是线程安全的,因为它是无状态的,它不包含任何域,也不包含任何其他类中域的引用,虽然所有线程都共享同一个SafeServlet实例,但是所有的线程都没有共享变量,每个线程都各行其是,没有交集,也不会相互影响。

所谓有状态,就像是我们在第一节提出来的,该对象存在着共享的变量,每个线程都可以访问这个变量。

像接下来的这个Servlet,用来统计处理次数:

public class UnsafeServlet implements Servlet{

    private long count = 0l;
    
    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException {
        String str1 = req.getParameter("param1");
        String str2 = req.getParameter("param2");
        res.getWriter().write(str1 + str2);
        count ++;
        
    }
}

 

该Servlet是有状态的,不同的线程调用service时,都会处理一个共享的变量:count。

count操作并不是原子的,它可以分解为三个步骤:

读取count的值;

修改count的值;

写count的值;

这三步操作可能在多线程访问时完全搞乱顺序。当线程A刚读取完,线程B也读取了值,但是线程A的执行时间片(https://zh.wikipedia.org/wiki/%E6%97%B6%E9%97%B4%E7%89%87)

完了,线程B理所当然地把count+1并且写入了count变量中。当线程A再次执行的时候,用到的count已经是过期的了。

并且其结果状态依赖上一个线程的处理。这会引发一系列不正确的结果。

无状态的类都是线程安全的,有状态的类,如果想线程安全,需要做一些并发控制。UnsafeServlet中的count,如果类型改成AtomicLong类型,这样可以把count++操作变成原子性的,因为AtomicLong对加一操作做了并发控制。由此我们可以想象,如果对状态的操作是原子性的,该对象也是线程安全的,当然方法不止这一种,后面将会提到。

4、竞态条件

这是维基百科的解释:https://zh.wikipedia.org/wiki/%E7%AB%B6%E7%88%AD%E5%8D%B1%E5%AE%B3

以上的UnsafeServlet中,由于不恰当的执行时序而出现的不正确的结果是非常典型的,我们称之为“竞态条件”。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会存在竞态条件。这种情况下是否返回正确的结果,完全靠运气。究其根本原因,就是可能基于一种已经失效的结果来做操作。

值得一提的是,设计模式:单例模式(https://zh.wikipedia.org/wiki/%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F),很容易被写成线程不安全的如下方式:

public class Singlton {
    private Object obj = null;
    
    public Object getInstance(){
        if(obj == null){
            obj = new Object();
        }
        return obj;
    }
}

 

这里包含一个竞态条件,它可能破坏这个类的正确性。不难分析,当两个线程都访问这个类想获取一个Object对象的时候,A、B都判定obj是null。A、B都会创建一个Object对象。那么他们可能返回不同的对象。维基百科上给出了安全的处理方式,这里就不再赘述了。

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或者之后读取和修改状态,而不是在在修改这个状态的过程中,也就是之前UnsafeServlet所描述的那样,将分步的操作复合成原子性的。实际情况中,尽可能能使用现有的线程安全对象来管理类的状态,这样更容易验证和维护线程的安全性问题。

5、加锁机制

UnsafeServlet的描述中,在Servlet中添加一个状态变量时,可以使用线程安全的对象来保证类的安全性,如果需要更多的状态变量时,是否只需要用线程安全的对象就可以保证线程安全了呢?不是这样的。

我们将代码稍作修改,添加一个变量记录最后一次请求的参数:

同样,省略了其他方法的实现。

public class UnsafeServlet implements Servlet{

    private AtomicLong count = new AtomicLong(0);
    private AtomicReference<String> lastParam1 = new AtomicReference<String>();
    
    public long getCount(){return count.get();}
    public String getLastParam1(){return lastParam1.get(); } 
    
    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException {
        String str1 = req.getParameter("param1");
        String str2 = req.getParameter("param2");
        res.getWriter().write(str1 + str2);
        count.incrementAndGet();
        lastParam1.set(str1);
    }
…
}

 

然而这种方式并不正确,虽然我们的两个变量都是原子性的,也是线程安全的,但是这个类中存在竞态条件。

在线程安全的定义中,要求多个线程之间的操作无论采用什么执行时序或者交替方式,都要保证结果正确。虽然使用了set操作是原子性的变量,但是service方法无法保证两次set操作整体是原子性的。

内置锁

Java提供了一种内置机制来支持原子性:同步代码块(synchronized)。同步代码块包括两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。静态的synchronized方法以Class对象作为锁。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁。

线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。无论是通过正常的控制路径退出还是通过代码块中抛出的异常退出,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

内置锁相当于一种互斥体,意味着当线程A进入同步代码块的时候,其他线程是无法进入代码块的,这样就可以保证代码块是原子性的。直到线程A释放锁,其他线程才能进入代码块。此处我们可以把整个servie方法都同步起来:

public synchronized void service(ServletRequest req, ServletResponse res)

但是这样的效率太低,因为这就相当于说明只能按顺序来访问该serlvet,违背了我们多线程访问的初衷,同样我们也可以只将操作属性的操作同步起来:

synchronized(this){count.incrementAndGet();lastParam1.set(str1);}

重入

重入指的是一种机制,当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞,然后由于内置锁是可以重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求是会成功的。也就是说自己请求自己的锁,是可以成功的,这种机制避免了一些情况

下死锁的发生。

上面的代码中,子类改写了父类的synchronized方法,然后又调用父类中的方法,此时如果没有可重入的锁,那么这段代码将死锁。由于Widget和ChildWidget中doSth方法都是synchronized的,因此每个doSth方法在执行前都会获取Widget上的锁,因为这个锁已经被持有,而线程将永远等待下去。

public class Widget {
    public synchronized void doSth(){
        System.out.println("Parent Do Sth");
    }
}
public class ChildWidget extends Widget{
    @Override
    public synchronized void doSth() {
        super.doSth();
    }
}

 

6、用锁来保护状态

由于锁可以保护代码按串行的形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。共享状态的复合操作,如:递增、单例模式里先判断后创建对象等,都必须是以原子操作以避免竞态条件的产生。仅仅将复合操作封装到一个同步代码块中是不够的,如果用同步来协调对某个变量的访问,那么所有访问这个变量的位置都需要使用同一个锁。

对象的内置锁与其状态之间没有内在的关联,当获取与其对象相关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获取同一个锁。

之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象,如果自行构造一个锁对象,那么久需要在程序中自始至终都使用它们。

每个共享的可变的变量都应该只由一个锁在保护,从而使得维护人员知道是哪一个锁。

一种常见的约定是,将所有的可变状态都封装在对象内部,并且通过对象的内置锁对所有访问可变状态的代码进行同步。

7、小结

本节主要讲述了如下几个点,非正式的说法对象的状态就是对象的值和属性,对象在多线程访问时,如果总是能返回正确的结果,那么这个对象就是线程安全的,无状态的类一定是线程安全的,如果对象中存在竞态条件,将会出现多线程访问数据正确性问题。Java提供了内置锁来支持对象的线程安全。线程可以获取自己的锁,这就叫重入,由于锁机制只能保证同一个锁不被不同的线程持有,我们用锁机制来保护对象的状态时,需要注意不变性条件中的每个变量都要使用同一个锁来保护。

线程安全性