《Java线程与并发编程实践》学习笔记3(初识线程同步)


(最近刚来到简书平台,以前在CSDN上写的一些东西,也在逐渐的移到这儿来,有些篇幅是很早的时候写下的,因此可能会看到一些内容杂乱的文章,对此深感抱歉,以下为正文)


引子

在平常的开发当中,我们往往要使用到多线程编程技术。当线程之间没有交互的时候,这种情况下程序将会变得比较简单。如果发生了交互,那么就必须考虑到多线程之间的安全问题,本篇来初步认识Java中如何使用同步的特性来保证线程的安全。

正文

线程中存在的问题

Java对线程的支持自然增强了其应用能力,但同时也增加了Java的复杂性,使得我们在开发的过程中必须要更加小心,否则多线程的程序中很有可能会出现一些难以察觉的bug。一般这些bug是由竞态条件、数据竞争以及缓存变量造成的。

竞态条件

在多线程程序中,如果程序的准确性取决于相对时间或者调度器调度线程运行的顺序时,这样就产生了竞态条件。在这里我们首先要明确的是竞态条件的产生并不是意味着程序直接出错了,而是指存在了出错的可能,因为调度器调度线程的未知性,如果当前调度顺序是按照理想顺序执行的话,那么程序运行就不会出现问题,反正则有可能出现问题,下面通过一些例子来描述竟态条件的发生。

package com.newway.syntest;

public class Test {

    private static int a = 10,b = 0;
    private static Thread t,t1;

    public static void main(String[] args) {
        t = new Thread(()->{
            if(a==10){
                try {
                    t1.join();
                } catch (Exception e) {
                    e.printStackTrace();
                }
                b = a / 10;
            }
        });

        t1 = new Thread(()->{
            a += 10;
        });

        t.start();
        t1.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("a:"+a+" b:"+b);

    }
}

执行上述代码后,可以在控制台看到如下打印:


控制台打印

上面的例子中,笔者通过join方法强制模拟了一种竞态条件产生的情况,我们创建了两个线程t和t1,并且类中包含了两个实例变量a和b,a初始值为0,b初始值为1,线程t中的操作是判断值a是否等于10,如果等于,则将a值除以10然后赋值给b,t1线程则是将a的值加上10然后重新赋值给a。在单线程情况下,t线程执行后b的值为1,但在多线程的情况下则不然。比如程序先执行t线程,此时a的值为10,当通过if判断,准备为b进行赋值时,此时调度器转到执行t1线程,将a的值改为了20,然后重新返回t线程继续执行,此时a的值已经是20,为b赋值后,b的值为2与理论值1不符,这就是竞态条件产生的错误。

上面模拟的场景事实上是竞态条件中的一个经典例子check-then-act,在这种竞态条件下,多线程有可能造成我们使用一种过时的观测状态来决定着我们之后的动作,就如上述的例子一样,线程t中是通过判断a的值是否为10来决定之后的操作,当通过判断后,其它线程在t线程执行操作前改变了a的值,然后t线程继续执行,此时a的值是20,理论上此时a的值应该无法通过检测,但因为之前在a的值未改变的时候已经通过了检测,尽管a的值已经更新了,但线程t仍然使用着过去的状态运行,程序继续执行后续的操作,从而得到了b值为2这种错误的状况。

除了check-then-act这种类型,还有一种比较常见的类型就是read-modify-write 。从字面意思可以看出,该种情况的理论运行顺序是先读取数据状态,然后修改数据状态,最后更新数据状态。通过这三个不可分割操作从而能得到更新后的结果。但在多线程中,如果你不加小心,这三个操作很有可能被分割开来,从而造成程序出现错误。同样,下面通过一个经典的小例子来描述这种情况。

package com.newway.syntest;

public class Test {

    private static int count = 0;

    public static void main(String[] args) {
        Runnable r = ()->{
            count ++;
        };

        for(int i = 0 ; i < 1000 ; i++){
            Thread t = new Thread(r);
            t.start();
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

在上述的例子中,我们有一个实例变量count,之后我们启动了1000个线程,线程中的操作只有count++,理论上这些操作如果是顺序操作的话,那么最后count的结果必定是1000,然而从控制台的输出可以看出,事实并不是这样。


控制台输出

其实造成这样结果的原因就在于count++这个操作上,乍一看,该操作似乎只是分一步执行,似乎是符合原子性操作的,多线程的运行应该不会影响到程序运行的结果才对,其实count++整体是分为三个操作步骤的,首先它读取了count的值,然后对其进行了修改,将count的值加1,最后将最新的count的值重新赋值给了count变量。

在多线程的运行环境下,因为不知道调度器在运行状态下的调度情况,这三个操作是有可能被分开的,假设线程1获取了当前count的值为1,此时线程2执行,也获取了当前count的值为1,然后线程1继续执行为count的值加1并重新赋值给count,count值为2,然后线程2继续执行,因为之前已经读取过count的值为1,此时加1,count值为2,相当于还原了线程1的操作,如果正常运行的话,此时的值应该为3,所以最终控制台打印的count的值不是1000就是因为发生了这种情况,这也是竞态条件的经典例子。

数据竞争

数据竞争也是多线程编程中引起出错的原因之一,我们也很容易把数据竞争和竞态条件弄混淆。数据竞争指的是两条或两条以上的线程并发地访问同一块内存区域,并且至少有一条线程是为了修改内存中的数据,如果我们没有人为协调好多线程对内存区域的访问,那么此时线程的访问顺序就是不确定的,这样就会产生数据竞争。下面通过一个小例子来描述一下数据竞争的产生:

package com.newway.syntest;

import java.util.concurrent.atomic.AtomicInteger;

public class Test {

    private static Object lock;
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) {
        Runnable r = ()->{
            lock = getInstance();
        };
        for(int i = 0 ; i < 10000 ; i++){
            Thread t = new Thread(r);
            t.start();
        }
        System.out.println("创造Object对象执行了"+count+"次");
    }

    public static Object getInstance() {
        if (lock == null) {
            lock = new Object();
            count.addAndGet(1);
        }
        return lock;
    }
}

执行上述代码可以从控制台看到如下打印:


控制台输出

上述的例子其实是想实现一个简单的单例模式,如果是在单线程环境下,那么Object对象只会被创建一次。在多线程环境下,因为我们没有对多线程进行约束管理,所以其访问顺序是未知的,假使线程1开始执行,因为第一次执行,所以在执行getInstance的时候,判断出当前lock为null,所以准备为lock变量值。此时线程2执行的时候,当它开始执行getInstance方法的时候,可能线程1已经为lock初始化过了,但也有可能此时线程1在更改lock状态时还没开始或还在更改中,那么此时,线程2仍然会将lock变量认为是null,为其初始化,这样就会发生明明是单例模式,却创建了两个Object对象,这样数据竞争就产生了。

缓存变量

为了提高程序的性能,程序中对数据的读取、修改并不都是在主存中进行的。Java虚拟机(JVM)和操作系统会进行协调,在寄存器中或者处理器缓存中进行缓存变量,这样相当于每个线程都有一份属于自己的操作数据,这样对其进行读取,修改就不再依赖于主存,提高了程序性能,线程操作结束后,再将修改过后的数据同步到主存中。这个设计本身确实提升了程序的性能,但在多线程的运行环境中,会有这么个一问题,当多个线程同时操作相同的数据时,线程本身是修改的是自己拷贝的变量,再没有写入主存中时,其它线程可能无法知道当前数据的改变,这样就会造成数据错误。

用一个简单的例子来描述的话可以这么说,假设我们有一个变量count,然后我们开启了两个线程对其进行++操作,Java会为每个线‘’‘’程开辟出一个单独的工作内存,然后会从主存中将count的值分别拷贝到每个线程的工作内存中,单个线程在对count值进行操作时,是针对自己的工作内存,之后才会同步到主存中,两个线程可能发生线程1操作后count值加1,线程2中的count值还是自己工作内存中的值并未加1,继续执行自增操作后将count值同步到主存中,相当于重复了一遍线程1的工作,最后count值相当于只自增了一次而并非预计的两次。

初识同步

从上面的介绍可以看出,多线程编程处处存在风险,稍有不慎,就有可能引发一些意想不到的错误,JVM中有一个特性同步可以用来解决上述的多线程的一些问题。
这里我们要先了解一个概念临界区,临界区就是指必须以串行方式运行的一段代码块。同步特性就是用于保证多线程运行环境下,同一个临界区在同一时间只能被一条线程执行。
同步特性也保证了数据的可见性,每一条线程进入临界区执行的时候,都会从主存中读取所需操作的变量值,从而保证操作时的变量值已经是最新的状态,这样就可以避免临时的缓存变量而引起的多线程问题。
java中的同步是通过监听器来实现的。java针对临界区构建了一种并发访问控制的机制,将java中的对象与监听器相关联,然后通过获取和释放监听器上的锁来实现并发控制。当一个线程持有某监听器的锁时,当其它的线程再准备获取该监听器的锁时,将会被阻塞。只有当持有锁的线程离开临界区后,释放掉锁资源后,其它线程才可以继续获取该监听器的锁。

synchronized关键字的简单使用

java提供了synchronized关键字来保障临界区中的代码是串行执行的。synchronized关键字可以作用在方法上或者代码块上。下面将分别通过一个小例子来分别讲述synchronized作用在方法和代码块上。

使用同步方法:

package com.newway.syntest;

public class Test {

    private static int id = 0;

    public static void main(String[] args) throws InterruptedException {

        Runnable r = () -> {
            getNextID();
            //id++;
        };

        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(r);
            t.start();
        }

        //此处将主线程睡眠500ms是为了等待上面的100个线程都执行完毕。
        Thread.sleep(500);
        System.out.println(id);

    }

    public synchronized static int getNextID() {
        return id++;
    }

}

多次执行上述代码可以在控制台看到如下打印:


控制台输出

可以看出通过使用synchronized关键字修饰的同步方法后,得到的id值和预估的一样,不会出现之前之前的id值小于100的情况。
如果将Runnable对象r上下文中调用的同步方法getNextID注释掉,并且将id自增操作的注释打开,再次多次执行,可以发现id的值无法保证每次都等于100,可能出现小于100的情况。
使用同步方法时,锁会与调用该方法的实例对象相关联,如果同步方法是类方法的话,那么锁对象会与调用该方法的类的class对象相关联,在上面的例子中就是与Test.class对象相关联。

使用同步代码块:

synchronized关键字除了能用来修饰方法,还可以用来修饰代码块,格式如下:

synchronized(object){
    /*statements*/
}

传入的Object类型参数将作为监听器的锁使用。下面将使用同步代码块的方式重复上个例子的实现:

package com.newway.syntest;

public class Test {

    private static int id = 0;
    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            synchronized (lock) {
                id++;
            }
        };
        for (int i = 0; i < 1000; i++) {
            Thread t = new Thread(r);
            t.start();
        }

        //这里让主线程睡眠了500ms是为了让上面的1000个线程先执行完毕
        Thread.sleep(500);
        System.out.println(id);
    }

}

多次执行上述代码都可以可以在控制台看到如下打印:


控制台输出

通过上面的例子我们可以看出,通过synchronized关键字我们可以实现多线程环境下,对临界区的同步管理。

活跃性问题

通过上面的篇幅我们了解了Java提供了synchronized关键字来帮助我们实现了线程的同步。但多线程编程并不仅仅是使用synchronized关键字就能解决所有问题了,暂且不谈synchronized关键字对于系统资源的开销比较大,单单就是其本身如果不正确使用的话也会产生很多的问题。
下面要引入一个概念:活跃性。活跃性这个概念本身没有明确的定义,可以理解为某件正确的事情最终会放生。而当某些操作导致程序无法继续执行下去的时候,这时候就发生了活跃性问题。在多线程编程时,我们需要时刻谨防活跃性问题。
在单线程的应用程序中,无线循环就是这样一个例子,程序无法继续执行下去。在多线程应用程序中,同样会发生活跃性问题,主要引起多线程活跃性问题的原因主要有:死锁、活锁、饿死。

死锁问题

在并发编程中,死锁问题是一种非常常见的逻辑错误。死锁的产生也是比较容易理解的,简单点儿说就是线程1在等待线程2释放持有的资源,线程2在等待线程1释放持有的资源,两者互相等待从而导致程序无法继续执行下去。
当然,死锁的问题并不是无法避免的,只要我们采用正确的方式,还是可以轻易地避免的。死锁的产生需要4个必要的条件:

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
  • 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
    我们只要使上述的4个必要条件中任意一个条件不成立,那么死锁问题就自然被打破了。下面就结合一些实例代码能更进一步地了解死锁问题。
package com.newway.syntest;

public class ThreadSynTest3 {

    private static Object lock1 = new Object();
    private static Object lock2 =  new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized(lock1){
                System.out.println("t1 get lock1 :" + Thread.holdsLock(lock1));
                System.out.println("t1 wait lock2 :" + Thread.holdsLock(lock2));
                synchronized (lock2) {
                    System.out.println("t1 get lock2 :" + Thread.holdsLock(lock2));
                }
            }
        });

        Thread t2 = new Thread(()->{
            synchronized (lock2) {
                System.out.println("t2 get lock2 :" + Thread.holdsLock(lock2));
                System.out.println("t2 wait lock1 :" + Thread.holdsLock(lock1));
                synchronized (lock1) {
                    System.out.println("t2 get lock1 :" + Thread.holdsLock(lock1));
                }
            }
        });

        t1.start();
        t2.start();
    }

}

执行上述代码,可以在控制台看到如下打印:


控制台打印

从控制台可以看出,线程t1,t2都在等待对方释放对应的资源锁,从而导致程序无法继续执行。这是一种最简单的死锁,我们可以轻易地看出,在实际场景中,一些死锁问题就相对地比较隐蔽,不是那么容易的就能找出,我们可能需要通过分析线程转储来分析死锁信息。
在平常的开发中,我们很少会在一个方法里显示地调用两个锁,但我们可能会隐式地调用外部的同步方法,这样本质上虽然仍然是在一个方法里得到了两个锁,但死锁的隐蔽程度大大增加了,让人不是那么容易地发现。下面通过一个小例子来展示这种情况:

package com.newway.syntest;

public class ThreadSynTest04 {
    private static A a = new A();
    private static B b = new B();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            a.method1(b);
        });

        Thread t2 = new Thread(() -> {
            b.method4(a);
        });

        t1.start();
        t2.start();
    }

}

class A {
    public synchronized void method1(B b) {
        System.out.println("A invoke method1");
        b.method3();
    }

    public synchronized void method2() {
        System.out.println("A invoke method2");
    }
}

class B {
    public synchronized void method3() {
        System.out.println("B invoke method3");
    }

    public synchronized void method4(A a) {
        System.out.println("B invoke method4");
        a.method2();
    }
}

执行上述代码,可以在控制台看到如下打印:


控制台打印

从控制台可以看出,程序产生了死锁,阻塞住无法继续向下执行。这里就是因为在同步方法中调用了其它的同步方法,从而隐式地获得了锁,从而造成了死锁现象。在平常的开发当中,我们经常会调用别人的方法,这里值得注意一下。(笔者这里举的例子没有很好的实际意义,仅仅是为了实现效果)

从理论上来说,避免死锁最简单的方式,就是阻止同步方法或者同步快调用其它的同步方法和同步块,但是通常的开发环境下,这明显是不现实的,不说其它的,光是Java自身的API中就存在着不少的同步方法。所以当发生死锁问题时,我们需要根据具体情况具体分析。

这里推荐一篇博客,《Java并发编程实践》笔记5——线程活跃性问题及其解决方法。这篇博客详细地说明了线程的活跃性问题,并列出了常见的几种死锁类型,并给出了相应的解决方法,笔者看完后收获颇多,特在此分享给大家。

活锁

活锁表示线程并没有被阻塞住,而是在重复地执行一个失败的操作,以至于程序无法继续执行下去。

活锁一般有两类:单一实体的活锁和协同导致的活锁。

单一实体的活锁的话好比一个线程从待执行的队列中取出开始执行,但是执行失败,程序重新将它放入到待执行的队列中,如果该任务一直执行失败,那么这个循环操作将会一直延续下去,从而产生了活锁。

协同导致导致的活锁的话就好比在通信中,为了防止发生冲突,需要冲突检测机制,假设两个线程一直发送检测信号,每次都发现信路上有其它的信号,然后重新发送检测,这样就又产生了一个循环的操作,从而产生了活锁。

饿死

饿死是一个线程长时间得不到资源从而无法执行的现象。可能是线程被调度器一直延迟访问,也有可能是其优先级太低总有高优先级的线程优于它执行从而导致一直无法执行。避免饿死的常用方式就是采用队列的方式,从而保证每个线程都有机会获得资源。

volatile和final变量

同步包括了两种属性:互斥性和可见性。前面synchronized关键字同时包含着两种属性。同时java还提供了一个更为轻量级的关键字,volatile,该关键字只包含了可见性。该关键字在前面将线程中断的时候就已经使用过,在笔者关于Java IO的篇幅中也提到过。
用volatile关键字修饰过的变量,在多线程环境中,每个线程将不会从主存中拷贝出一份副本放入工作内存中,每一次使用该值时,都会从主存中读取,这样就保证了数据的可见性。
下面用一个简单的小例子来演示一下:

package com.newway.syntest;

public class Test {

    private static boolean stop;

    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            for (;;) {
                if (stop) {
                    return;
                }
            }
        };
        for (int i = 0; i < 100; i++) {
            Thread t = new Thread(r);
            t.start();
        }
        Thread.sleep(50);
        stop = true;
        System.out.println("stop is been true");
    }

}

执行上述代码可以在控制台看到如下打印:


控制台输出

当boolean型控制开关stop已经被置为true时,我们可以看到仍然有线程在执行者,这就是数据的可见性问题。如果我们为stop变量加上volatile关键字进行修饰,即:

private static volatile boolean stop;

再次运行程序,可以在控制台看到如下打印:


控制台输出

当stop控制变量置为true的时候,所有的线程都结束了活动。

除了上述的volatile关键字可以保证在多线程环境下的数据可见性,Java中的final关键字同样能保证在多线程环境下数据是安全的。对于被final关键字修饰过的数据,Java提供了一种特殊的线程安全的保证。即便没有用同步来进行约束,它们依然可以被多线程安全地进行访问。被final修饰的对象可以称为不可变对象,它们提供了下列的一些规则:

  • 不可变对象绝对不允许状态变更
  • 所有属性必须声明成final
  • 对象必须被恰当地构造出来已放引用显示地脱离构造函数

关于final关键字对于线程安全这块儿的使用,可以用一个经典的单例模式的小代码来演示,代码如下:

public class Singleton {  
    private static class SingletonHolder {  
    private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
    return SingletonHolder.INSTANCE;  
    }  
}   

上述代码就是一个比较经典的单例模式。通过final关键字的使用保证了它的线程的安全性。
最后还提供了一个专门讲述线程安全的网站:Java theory and practice Safe construction techniques

以上为本篇的全部内容。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容