Java多线程设计(三)线程安全问题

1. 概述

我们经常会听到线程安全非线程安全,比如说StringBuffer 是线程安全的,StringBuilder是非线程安全的。又比如说 Vector 是线程安全的,ArrayList是非线程安全的,然后又说线程安全的效率相对非线程安全的要低一些等等。那么线程安全和非线程安全到底是什么呢?

2. 非线程安全

非线程安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。先来看个例子,感受一下非线程安全。

打印机大家很熟,我宿舍楼下就有家打印店,现在试想一下这样子的场景。有三个学生,要去打印自己的名字,打印店只有一台打印机。那么现在用代码来模拟一下这个场景。

(1)打印机类Printer

public class Printer {

    private String username;
    private String content;

    public void print(String username, String content) {
        this.username = username;
        this.content = content;
        // 检查数据是否有错
        check();
        // 打印....
    }

    private void check() {
        if (!username.equals(content)) {
            System.out.println("Thread:"+Thread.currentThread().getName()+"------>name=" + username + ",content=" + content);
        }
    }
}

Printer类有2个字段

  • username是记录用户的名字;
  • content是要打印的内容;

打印方法print接受两个参数,一个是用户的名字,一个是要打印的内容。需要特别注意的是,在执行打印操作前,打印机会调用check方法来检查打印是否出错,因为学生打印的是自己的名字,所以就判断用户的名字和打印的内容是否相同,不相同则打印出错输出出错信息,其中错误信息就包括了当前线程、用户名和打印内容。

(2)学生类Student


public class Student extends Thread{

    private String name;
    private Printer printer;

    public Student(Printer printer,String name) {
        super(name); //设置线程的名字
        this.printer = printer;
        this.name = name;
    }

    @Override
    public void run() {
        while(true){
            printer.print(name, name);
        }
    }
}

Student类是一个线程类,它继承了Thread,并重写了run方法,并在run方法中不断地使用打印机打印自己的姓名。

(3)主程序类Main

public class Main {

    public static void main(String[] args) {

        Printer printer = new Printer();
        new Student(printer, "张三").start();
        new Student(printer, "李四").start();
        new Student(printer, "王五").start();
    }
}

main方法中创建了一个打印机类实例,创建了3个学生类实例使用该打印机,并启动了这3个学生类线程。

(4)输出结果的一部分:

Thread:王五------>name=张三,content=张三
Thread:王五------>name=张三,content=张三
Thread:李四------>name=张三,content=张三
Thread:李四------>name=王五,content=王五
Thread:李四------>name=王五,content=王五
Thread:李四------>name=王五,content=王五
Thread:李四------>name=王五,content=王五
Thread:李四------>name=张三,content=张三
Thread:李四------>name=王五,content=王五

当然了,输出结果是远不止这些的,不过这些就已经可以看出问题所在了。可以看到,Printer类的check方法检查出了很多错误。

还记得一开头说的“非线程安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据”,说的就是这类事情。

现在就拿上面输出的错误信息的第一条来分析一下,王五的线程,执行打印的结果如果按照我们的期待的话,是应该打印出

Thread:王五------>name=王五,content=王五

但是实际上却打印出了

Thread:王五------>name=张三,content=张三

会发生这种情况,是因为王五线程在check方法前Printer的字段username和字段content被张三线程修改了,于是王五线程执行check方法时,usernamecontent的值已经发生了改变。

还有判断是否错误的条件是username是否等于content,那上面这个错误输出的usernamecontent都是张三,这样子判断应该是对的,为什么还是会输出错误信息呢?其实这也是一样的道理,在进行判断是否出错的时候,这两个字段的值是不一样的,但是再往下执行的时候,这两个字段的值又被其它线程给修改了,刚好又修改成一样比如说张三。

当然了,出错的情况也不是只有这么一种,还有可能是下面这种

Thread:王五------>name=张三,content=李四

这样子说明,参与修改数据的线程不止有张三线程,还有李四线程。

非线程安全造成的问题是很难预料的,一个方法可能执行了上万次才会出现一次错误。我在Printer类中加一个字段counter去记录print方法被调用的次数。

修改后的Printer

public class Printer {

    private String username;
    private String content;
    private int counter = 0;

    public void print(String username, String content) {
        counter++;
        this.username = username;
        this.content = content;
        // 检查数据是否有错
        check();
        // 打印....
    }

    private void check() {
        if (!username.equals(content)) {
            System.out.println("count:"+counter+"  Thread:"+Thread.currentThread().getName()+"------>name=" + username + ",content=" + content);
        }
    }
}

输出结果的一部分:

counter:2892  Thread:张三------>name=王五,content=王五
counter:179473  Thread:张三------>name=王五,content=王五
counter:208701  Thread:王五------>name=张三,content=张三
counter:1639848  Thread:李四------>name=王五,content=张三

可以发现两次错误信息之间调用print方法的次数已经高达十多万,而且出错的结果可能还各不相同。所以这类问题很难发现,可能在一个软件的生命周期中都不会出现,但是一旦出现便是灾难性的!

要解决这个问题,最简单粗暴的就是不使用多线程访问数据,用单线程就好了,但是这样就像是饮鸩止渴了,所以又有下面的解决办法。

3. 线程安全

要解决上面产生的问题,就需要思考一个问题,是什么数据出错了?很明显上面的问题是字段username和字段content出问题了,在进行检查的时候这两个字段的值可能会被其它线程修改,这样子就出现错误了。

这里就涉及到一个原子性操作的问题,在打印方法print中,可以说出大概三步操作:

  • 设置需要打印的内容;
  • 检查需要打印的内容是否正确
  • 进行打印操作

按照我们原始的意愿,这三步操作是要顺序执行,而且在执行过程中,前面代码的执行结果是不会改变的。但是在多线程访问的时候,这三个步骤虽然是顺序执行,但是在执行过程中,前面的结果可能已经被其它线程改变了。所以我们要把这三步弄成一步的样子,就是中间不能有其它线程穿插进来。这样子就把print方法变成原子操作(atomic operation),即是不可分割的。

要完成上面的目的,只需要让print成为synchronized方法即是同步方法:

public synchronized void print(String username, String content) {
    counter++;
    this.username = username;
    this.content = content;
    // 检查数据是否有错
    check();
    // 打印....
}

这样子一来,就不会出现上一节出现过的错误。在一个线程进入print方法后其它线程就不能进入该方法,直到执行print方法的线程退出该方法释放锁。这样子,一个线程在执行检查和打印操作的时候,就不会存在其它线程同时修改usernamecontent的情况。

每个线程中访问临界资源的那段程序称为临界区,print方法就是临界区(critical section),访问的资源就是临界资源,像usernamecontent就是临界资源。

现在,print方法就是一个线程安全的方法。

4. 效率问题

那么为了安全是不是索性把被多线程操作的方法都加上synchronized呢?当然不是了,世界上没有完美的事。线程安全的方法的效率要比非线程安全的方法低,修改一下上面的程序,你就可以看到差距。

(1)Student类的run方法我改成下面这样子

public void run() {
    long start = System.currentTimeMillis();
    for(int i = 0;i<10000000;i++){
        printer.print(name, name);
    }
    long end = System.currentTimeMillis();
    System.out.println(name+":"+(end - start));
}

现在run方法内没有死循环,只是让打印方法执行10000000次,并且把整个run方法执行的时间打印出来。

(2)主程序Main类不用改。

(3)Printer类的print方法是同步方法时,即带有synchronized关键字。

执行后控制台输出结果:

李四:1389
张三:1404
王五:1409

这是线程安全的方法的效率

(4)现在将Printer类的print方法去掉synchronized关键字,该方法此时是非线程安全的。

执行后控制台输出结果:

王五:13
李四:41
张三:41

当然了,在上面三条结果中还穿插着很多错误信息,不过我们只需要关注这三句。

通过上面的结果大家可以看出同步方法的效率和比非同步方法的差了很多倍。这个道理其实挺简单的,就像是我们要家里安全就需要装大门,安全是安全了,但装这个大门得花钱。

虽然线程安全效率会降低,但是这却是必须的,我们可以左扣一点右扣一点把效率提高一下,比如说尽可能缩小同步的范围,不过前提是得确保安全。

注意了,线程安全问题只是会发生在多线程操作的情况,如果确定是只有单线程执行操作的话,加synchronized就没必要了,不过你加了也没错,只是性能降低点。就像是你确定家里只有一个人(单线程),而且不会有人来,那你上厕所就不用关上门了,关上门(加synchronized)也可以,只不过慢一点拉(看看急不急咯)~~

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值