Java并发编程入门(十八)再论线程安全

一、无需加锁的线程安全场景

如下几种场景无需加锁就能做到线程安全:

1.不变对象

2.线程封闭

3.栈封闭

4.ThreadLocal

I、不变对象

经典并发编程描述对象满足不变性有以下条件:

1.对象创建后状态就不再变化。

2.对象的所有域都是final类型。

3.创建对象期间,this引用没有溢出。

实际对于第2点描述不完全准确:

1.只要成员变量是私有的,并且只提供只读操作,就可能做到线程安全,并不一定需要final修饰,注意这里说的是可能,原因见第2点。

2.如果成员变量是个对象,并且外部可写,那么也不能保证线程安全,例如:

public class Apple {
    public static void main(String[] args) {
        Dictionary dictionary = new Dictionary();
        Map<String, String> map = dictionary.getMap();
        //这个操作后,导致下一步的操作结果和预期不符,预期不符就不是线程安全
        map.clear();
        System.out.println(dictionary.translate("苹果"));
    }
}

class Dictionary {

    private final Map<String, String> map = new HashMap<String, String>();

    public Dictionary() {
        map.put("苹果", "Apple");
        map.put("橘子", "Orange");
    }

    public String translate(String cn) {
        if (map.containsKey(cn)) {
            return map.get(cn);
        }
        return "UNKONWN";
    }

    public Map<String, String> getMap() {
        return map;
    }
}
复制代码

因此对不变对象的正确理解应该是:

1.对象创建后状态不再变化(所有成员变量不再变化)

2.只有只读操作。

3.任何时候对象的成员都不会溢出(成员不被其他外部对象进行写操作),而不仅仅只是在构建时。

另一些书籍和培训提到不变类应该用final修饰,以防止类被继承后子类不安全,个人觉得子类和父类本身就不是一个对象,我们说一个类是否线程安全说的是这个类本身,而不需要关心子类是否安全,唯一需要注意的是:父类的成员变量是protected修饰,子类继承后修改了它,然后子类和父类又被同一个线程调用,导致父类不安全。

II、线程封闭

如果对象只在单线程中使用,不在多个线程中共享,这就是线程封闭。

例如web应用中获取连接池中的数据库连接访问数据库,每个web请求是一个独立线程,当一个请求获取到一个数据库连接后,不会再被其他请求使用,直到数据库连接关闭(回到连接池中)才会被其他请求使用。

III、栈封闭

对象只在局部代码块中使用,就是栈封闭的,例如:

     public void print(Vector v) {
         int size = v.size();
         for (int i = 0; i < size; i++) {
             System.out.println(v.get(i));
         }
     }
复制代码

变量size是局部变量(栈封闭),Vector又是线程安全的容器,因此对于这个方法而言是线程安全的。

VI、ThreadLocal

通过ThreadLocal存储的对象只对当前线程可见,因此也是线程安全的。

二、常见误解的线程安全场景

I、线程安全容器总是安全

有些容器线程安全指的是原子操作线程安全,并非所有操作都安全,非线程安全的操作如:IF-AND-SET,容器迭代,例如:

public class VectorDemo {

    public static void main(String[] args) {
        Vector<String> tasks = new Vector<String>();
        for (int i = 0; i < 10; i++) {
            tasks.add("task" + i);
        }

        Thread worker1 = new Thread(new Worker(tasks));
        Thread worker2 = new Thread(new Worker(tasks));
        Thread worker3 = new Thread(new Worker(tasks));

        worker1.start();
        worker2.start();
        worker3.start();
    }
}

class Worker implements Runnable {

    private Vector<String> tasks;

    public Worker(Vector<String> tasks) {
        this.tasks = tasks;
    }

    public void run() {
        //如下操作非线程安全,多个线程同时执行,在判断时可能都满足条件,但实际处理时可能已经不再满足条件
        while (tasks.size() > 0) {
            //模拟业务处理
            sleep(100);
            //实际执行时,这里可能已经不满足tasks.size() > 0
            System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
        }
    }

    private void sleep(long millis) {
        try {
            TimeUnit.MILLISECONDS.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
复制代码

输出日志:

Thread-0 task0
Thread-1 task2
Thread-2 task1
Thread-1 task3
Thread-2 task5
Thread-0 task4
Thread-0 task6
Thread-1 task8
Thread-2 task7
Thread-1 task9
Exception in thread "Thread-0" Exception in thread "Thread-2" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
	at java.util.Vector.remove(Vector.java:831)
	at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
	at java.lang.Thread.run(Thread.java:745)
java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
	at java.util.Vector.remove(Vector.java:831)
	at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
	at java.lang.Thread.run(Thread.java:745)
复制代码

可以看到其中一个工作线程在tasks.remove(0)时,由于集合中已经没有数据而抛出异常。要做到线程安全则要对非原子操作加锁,修改后的代码如下:

    public void run() {
        //对非原子操作加锁
        synchronized (tasks) {
            while (tasks.size() > 0) {
                sleep(100);
                System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
            }
        }
    }
复制代码

II、final修饰的对象线程安全

在上述例子中,即使用final修饰Vector也非线程安全,final不代表被修饰对象是属于线程安全的不变对象。

III、volatile修饰对象线程安全

volatile关键字修饰的对象只能保证可见性,这类变量不缓存在CPU的缓存中,这样能保证如果A线程先修改了volatile变量的值,那么B线程后读取时就能看到最新值,而可见性不等于线程安全。

三、狭义线程安全和广义线程安全

我们说Vector是线程安全的,但上面的例子已经说明:并非所有场景下Vector的操作都是线程安全的,但明明Vector又被公认为是线程安全的,这怎么解释?

由此,我们就可以定义狭义线程安全和广义线程安全:

1.狭义:对象的每一个单个操作均线程安全

2.广义:对象的每一个单个操作和组合操作都线程安全

对于上面例子中的Vector要修改为广义线程安全,就需要在remove操作中做二次判断,如果容器中已经没有对象,就返回null,方法签名可以修改为existsAndRemove,当然,为了做到广义线程安全,修改的方法还不仅仅只有这一个。

四、总结

本文描述了不加锁情况下线程安全的场景,以及容易误解的线程安全场景,再到狭义线程安全和广义线程安全,理解了这些,可以让我们更清楚何时该加锁,何时不需要加锁,从而更有效的编写线程安全代码。

end.


相关阅读:
Java并发编程(一)知识地图
Java并发编程(二)原子性
Java并发编程(三)可见性
Java并发编程(四)有序性
Java并发编程(五)创建线程方式概览
Java并发编程入门(六)synchronized用法
Java并发编程入门(七)轻松理解wait和notify以及使用场景
Java并发编程入门(八)线程生命周期
Java并发编程入门(九)死锁和死锁定位
Java并发编程入门(十)锁优化
Java并发编程入门(十一)限流场景和Spring限流器实现
Java并发编程入门(十二)生产者和消费者模式-代码模板
Java并发编程入门(十三)读写锁和缓存模板
Java并发编程入门(十四)CountDownLatch应用场景
Java并发编程入门(十五)CyclicBarrier应用场景
Java并发编程入门(十六)秒懂线程池差别
Java并发编程入门(十七)一图掌握线程常用类和接口


Java极客站点: javageektour.com/

猜你喜欢

转载自juejin.im/post/5da338766fb9a04e0a37f4d1