线程不安全

众所周知,多线程访问同一公共资源会带来线程的不安全,本文探讨一下这个问题的若干细节。

关于线程安全的基本问题

有关线程安全常涉及两个概念:

竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。
临界区:导致竞态条件发生的代码区称作临界区(线程不安全的代码区)。

线程安全与竞态条件:线程安全的代码区不存在竞态条件,线程不安全的代码区(临界区)存在竞态条件。

为什么会出现线程安全问题?
道理很简单,多个线程同时访问(写操作)同一个公共资源必然带来问题,我们举2个例子:
例1:线程A,线程B同时拿到全局变量i(值为0)并存储在自己的本地栈中,线程A对i加1,线程B也对i加1,那么线程A,线程B提交后,i的结果为1,而不是我们期望的结果2。
例2:线程A,线程B同时拿到全局变量i(值为0)并存储在自己的本地栈中,线程A对i加1,线程B随后读取i,那么线程B读取的结果依然为0,而不是我们期望的结果1。这就是多线程并发导致的可见性问题。

如何解决线程不安全?
临界区进行同步,从而避免竞态条件。如:使用synchronized或JUC中的Lock对临界区加锁。给临界区加锁好比给公园的一个厕所加了一把锁,避免了人们共同进入厕所的问题,而必须是只有拿到钥匙的人才能进入(这个例子有点那个,但是我总会联想到这个例子)。

举例说明

既然多线程引发的安全问题是因为同时访问同一个公共资源导致的,相应的,如果多线程访问的不是公共资源也就不会发生线程安全问题。下面按资源是否公共举几个例子来说明问题。

公共资源:对象的成员变量

对象的成员变量:成员变量存储在共享堆上,如果两个线程同时更新同一个对象的同一个成员变量,那这个代码就不是线程安全的。
示例代码:

public class ThreadSafe_ {
    public static void main(String[] args) {
        Obj obj = new Obj();
        MyRunnable task = new MyRunnable(obj);
        new Thread(task,"t1").start();
        new Thread(task,"t2").start();
    }
}
class Obj {
    StringBuilder noSafeBuilder = new StringBuilder();// 对象的成员变量:成员变量存储在共享堆上,如果两个线程同时更新同一个对象的同一个成员变量,那这个代码就不是线程安全的。

    void add(String text){
        noSafeBuilder.append(text);
    }
}
class MyRunnable implements Runnable{
    private Obj obj = null;

    MyRunnable(Obj obj){
        this.obj = obj;
    }

    @Override
    public void run() {// 临界区(线程不安全的代码区,因为多个线程可能同时修改成员变量noSafeBulider,所以会带来问题)
        this.obj.add(" "+Thread.currentThread().getName());
        System.out.println(Thread.currentThread().getName()+"=>"+this.obj.noSafeBuilder.toString());
    }
}

 打印结果:

t2=> t1 t2
t1=> t1 t2

当然,上面run方法因为线程不安全,无法保证线程的执行顺序,所以上面的代码运行多次可能带来多个结果:在这段代码中,我们期望的是,Obj对象的noSafeBuilder属性先追加1个线程名然后打印这个线程名,然后noSafeBuilder再追加第2个线程名并打印追加的2个线程名,但从打印结果看,第1个线程在执行完run方法后就已经打印了2个线程名——这就是线程不安全所带来的问题。

从上图可以知道,线程不安全会带来多少问题。

解决办法:

class MyRunnable implements Runnable{
    private Obj obj = null;

    MyRunnable(Obj obj){
        this.obj = obj;
    }

    private final Object lock = new Object();

    @Override
    public void run() {
        synchronized (lock) {//临界区 加锁同步
            this.obj.add(" "+Thread.currentThread().getName());
            System.out.println(Thread.currentThread().getName()+"=>"+this.obj.noSafeBuilder.toString());
        }
    }
}

打印结果:

t1=> t1
t2=> t1 t2

由于t2可能先执行,所以上面代码的打印结果也可能为:

t2=> t2
t1=> t2 t1

非公共资源:局部基本类型变量

局部变量存储在线程自己的栈中,也就是说,局部变量永远也不会被多个线程共享。如:

public class ThreadTest {
    public static void main(String[]args){
        MyThread share = new MyThread();
        for (int i=0;i<50;i++){
            new Thread(share,"线程"+i).start();
        }
    }
}

class MyThread implements Runnable{
    public void run() {
        int a =0;
        ++a;
        System.out.println(Thread.currentThread().getName()+":"+a);
    }
}

无论多少个线程对run()方法中的基本类型a执行++a操作,只是更新当前线程栈的值,不会影响其他线程,也就是不共享数据。

特殊资源:局部的对象引用

为什么这个是特殊示例呢?因为对象的局部引用和基础类型的局部变量不太一样,尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内。所有的对象都存在共享堆中。这就意味着访问对象的引用的代码可能是线程安全的,也有可能是线程不安全的——这主要取决于:在某个方法中创建的对象是否会逃逸(即该对象不会被其它方法获得,也不会被非局部变量引用到),如果不会,那么这个方法就是线程安全的,反之就是不安全的。

实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。如:

    public void method1(){
        LocalObject localObject = new LocalObject();
        localObject.callMethod();
        method2(localObject);
    }

    public void method2(LocalObject localObject){
        localObject.setValue("value");
    }
posted @ 2021-02-09 02:18  Tom1997  阅读(285)  评论(0编辑  收藏  举报