java并发编程实战之如何解决线程安全

版权声明:转载注明出处 https://blog.csdn.net/nobody_1/article/details/82229199

通过上篇文章了解到多线程编程在提升系统性能的同时,也带来了线程安全以及竞态条件等问题 。这篇文章主要是用来阐述出现多线程安全问题的原因以及解决的思路。

① 多线程中安全问题的具体体现

public class ThreadQuestion {
private int i;
private static ThreadQuestion tq = new ThreadQuestion(1);

    public ThreadQuestion(int i) {
        this.i = i;
    }
    public void add() {
        i++;
        System.out.println("i的值:" + i);
    }

    public static void main(String[] args) {

        Runnable run1 = new Runnable() {
            @Override
            public void run() {
                tq.add();
                System.out.println("线程t1计算的i值:" + tq.i);
            }
        };

        Runnable run2 = new Runnable() {
            @Override
            public void run() {
                tq.add();
                System.out.println("线程t2计算的i值:" + tq.i);
            }
        };

        Thread thread1 = new Thread(run1);
        Thread thread2 = new Thread(run2);

        thread1.start();
        thread2.start();
    }
}

代码大意:在JVM中启动三个线程,包括一个main线程,两个计算线程(t1、t2),三个线程共享一个静态的对象引用tq, t1和t2线程分别调用对象实例的add()方法来修改对象的内容,即i的值。
注意:对象引用tq声明为static,使得tq能够成为共享变量。【思路:tq如果放在main线程里面,该如何实现共享?
执行结果:

结果1i的值:2
线程t1计算的i值:2
i的值:3
线程t2计算的i值:3
结果2i的值:2
线程t1计算的i值:3
i的值:3
线程t2计算的i值:3
结果3i的值:2
i的值:3
线程t2计算的i值:3
线程t1计算的i值:3
结果3i的值:3
线程t2计算的i值:3
i的值:3
线程t1计算的i值:3

② 实例代码结果分析

多次运行实例代码却能得出不同的结果,而且每次的结果都不能保证正确性,这就是线程的“安全性问题”。这时脑子里只会有一个疑问:怎么会这样?我很懵逼!是的,确实很懵逼。根据代码结果可以对执行顺序做一个简单的分析:
结果1:
1.线程1启动
2.执行线程1的tq.add()方法
3.执行线程1中add()方法的i++(此时i的值为初始值1,结果为2)
4.执行线程1中add()方法的printLn()(即打印“i的值:2”)
5.执行线程1中printLn()(即打印“线程t1计算的i值:2”)
6.[线程2启动]
7.执行线程2的tq.add()方法
8.执行线程2的add()方法的i++(此时i的值为2,结果为3)
9.执行线程2的add()方法的printLn()(即打印“i的值:3”)
10.执行线程2中printLn()(即打印“线程t2计算的i值:3”)

很明显,以上分析过程是符合常理的,按照类似于串行的逻辑执行,得到的结果自然也是正确的。
注意:[线程2启动]可以位于之前的任何一个执行过程,但之后的执行过程以及顺序是不可改变的,否则无法得到结果1。

结果2:
1.线程1启动
2.执行线程1的tq.add()方法
3.执行线程1中add()方法的i++(此时i的值为初始值1,结果为2)
4.执行线程1中add()方法的printLn()(即打印“i的值:2”)
5.[线程2启动]
6.执行线程2的tq.add()方法
7.执行线程2中add()方法的i++(此时i的值为2,结果为3)
8.执行线程1中printLn()(即打印“线程t1计算的i值:3”)
9.执行线程2中add()方法的printLn()(即打印“i的值:3”)
10.执行线程2中printLn()(即打印“线程t2计算的i值:3”)

由于没有对线程的执行顺序加以控制,使得执行过程杂乱无章,导致得出的结果不正确。不正确是指:“线程t1计算的i值:3”,正确打印结果应该是“线程t1计算的i值:2”。不正确的原因是:线程1中打印语句执行之前线程2已经执行了add()方法,导致引用tq的内容i值发生改变,所以打印时读取的i值内容也就是改变后的值。那么如何解决呢?

结果3:
无法知道是线程1还是线程2先执行add()方法,大概思路与结果2类似。

结果4:[最萌比的结果,怎么两线程都是3?]
具体的执行顺序可以按照上面的思路来分析,可是无论如何也不应该出现这样的结果!仔细分析,问题只能出现在add()方法里,add()方法的关键代码是i++;所以i++里面肯定有猫腻。
i++操作在java里面是i = i + 1的简写,看似简单的外表其实包含着三个操作:读取i的值,将值加1,然后计算结果赋值给i,即“读取-修改-写入”的操作序列,并且结果状态依赖于之前的状态。那么可能出现情况:线程1和线程2都执行到add()方法,线程1读取i的初始值1,修改i值为2,线程2读取i的值为2,修改为3,线程1和线程2赋值给i值为3,过程简化图如下图:
简化图
这种结果明显是不正确的。那么如何解决?

③ 寻根问底,问到底

《java并发编程实战》第二章第二节利用“命中计数器”引出了原子性概念,利用“星巴克会面”引出了竞态条件。这里引用书中的话来解释竞态条件:由于不恰当的执行时序而出现不正确的结果,称为竞态条件。结果2、结果3和结果4都存在竞态条件;另外结果4中还存在“数据竞争”,书中这样解释:如果在访问共享的非final类型的域(变量)时没有采用同步来进行协同,那么就会出现数据竞争。我理解的数据竞争是:多个线程同时访问没有同步且可变的资源而出现的情况。
很明显,竞态条件和数据竞争是错误的根本原因。

④ 对症下药,药到病除

《java并发编程实战》一书中指出:避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或者之后读取或者修改状态,而不是在修改状态的过程中。即线程1对i值的“读取-修改-写入”这个复合操作完结是线程2读取i值的前提条件,即保证“读取-修改-写入”符合操作的原子性,以确保线程安全性

-> java提供了一种内置的锁机制来支持原子性:同步代码块(synchronized Block)。
同步代码块在java中的体现:

synchronized(lock){
    // 访问或者修改由锁保护的共享变量
}

同步代码块由synchronized关键字实现;lock是指对象的内置锁,java中每一个对象都可以作为一个实现同步的锁,这个锁可以保证代码块只能被一个线程执行,即保证代码块的原子性。
注意:线程在进入同步代码块自动获得内置锁,退出同步代码块自动释放内置锁。

修改后的方法:

@Override
public void run() {
    synchronized (tq) {
        tq.add();
        System.out.println("线程t2计算的i值:" + tq.i);
    }
}

-> 鉴于synchronized同步代码块只能适用于简单的场景,JAVA开发团队还提供一个单独的类作为锁来实现同步代码块。对,就是ReentrantLock(重入锁), 可以这样理解:re-enrtrant-lock,即可以再次进入的锁;大概意思就是:同一个线程可以重复获取同一个对象的锁;假设情况:

public class Parent {
    public synchronized void doSomething(){
        // do something...
    }
}

class Son extends Parent{
    public synchronized void doSomething(){
        //do somthing...
        super.doSomething();
    }
}

子类的同步方法调用父类的同步方法,因为根据里氏替换原则知道,任何调用父类的地方都可以看做子类的调用,所以如果没有重入锁那么这段代码将产生死锁。当然内置锁也具有重入特性,《java并发编程实战》中这样解释重入的原理:为每个锁关联一个获取计数值和一个所有者线程;当计数值为0,这个锁就被认为是没有被任何线程持有;当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将计数值置为1;如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应的递减;当计数值为0时,这个锁将被释放。采用ReentrantLock关键字修改后的代码如下:

@Override
public void run() {
    reentrantLock.lock();
    try{
        tq.add();
        System.out.println("线程t1计算的i值:" + tq.i);
    }finally{
        reentrantLock.unlock();
    }
}

reentrantLock变量在ThreadQuestion类中声明为private static类型。[思考一下ReentrantLock与对象的内置锁有什么区别?]

至此,我们根据多线程编码实例,到多种结果,分析各种结果的原因,到深入理解问题本质,最后解决问题可以知道以下几点:
->多线程执行非原子操作的代码或者方法容易导致线程不安全;
->可以通过同步机制使非原子操作变成只能由一个线程访问的原子操作;
->对象的内置锁是同步机制的一种实现方式,锁有内置锁和ReentrantLock两种;

参考资料:
《java并发编程实战》
《实战java高并发程序设计》

关于多线程基础:《java并发编程实战之多线程基础》

猜你喜欢

转载自blog.csdn.net/nobody_1/article/details/82229199