Java并发编程学习三:线程同步的关键字以及理解

上篇文章中介绍了Java线程的带来的问题与内存模型中介绍了线程可能会引发的问题以及对应Java的内存模型,顺带介绍了Volatile和Sychronized关键字。今天对Java中涉及到的常见的关键类和关键字进行一个总结。


Volatile

与锁相比,Volatile提供了一种更加轻量级的同步机制,使用Volatile的变量在多线程中是不会发生上下文切换或者线程调度等操作的。当一个变量定义成为一个Volatile的时候,这个变量具备了两种特性:

  • 第一是保证了该变量对所有线程的可见性。
  • 第二是禁止指令重排序优化。

Volatile变量不会缓存在工作内存(对应物理寄存器)当中,在线程A中修改了一个共享变量的值,修改后立即从A的工作内存中同步给了主内存更新值,同时其他线程每次使用该共享变量值时,保证从主内存中获取。不过Volatile也有一定的局限性,虽然提供了相似的可见性保证,但不能用于构建原子的复合操作,因此当一个变量依赖其他变量,或者当前变量依赖与旧值时候,就不能使用Volatile变量,因为Volatile不保证代码的原子性。最常见的就是自增操作的问题。

由于Java中的运算并非原子操作,所以在多线程的情况下进行运算一样是不安全的。示例Demo如下:


class ThreadTest {


    private volatile int count = 0;

    public void update() {

        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                for (int k = 0; k < 100; k++) {
                    count++;
                }

            });
            thread.start();
        }
        try {
            Thread.sleep(5000);
            System.out.println(count);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

上面代码获取到的值基本上都是小于5000的,因为count++在执行过程中分三步进行,首先从主存中复制count到工作内存中,工作内存中将count+1,然后在再刷新回主存。所以存在的问题是当一个进行前两步的时候,其他的线程已经刷新最新值回主存了,那么当前线程再刷新回主存的时候造成了值变小的问题。

Volatile最常见的场景就是在线程中充当flag变量的标志,如提供一个方法进行终止线程:

class ThreadTest extends Thread {


    private volatile boolean isCancle;

    public void setCancle(boolean isCancle) {
        this.isCancle = isCancle;
    }

    @Override
    public void run() {
        super.run();
        while (!isCancle) {

        }
        System.out.println("over");
    }
}

当调用setCancle(...)的时候能够立马结束while循环,从而打印出over。

第二个,使用Volatile能够禁止指令重排序的优化。在Java线程的带来的问题与内存模型(JMM)中我们解释了指令重排序的概念,那么在Java中可以通过Volatile关键字添加内存屏障,从而实现禁止指令重排序的优化,关于Volatile禁止指令重排序的一个在经典的案例就是DCL中的使用:


public class DoubleCheckedLocking {            
    private static Instance instance;              

    public static Instance getInstance() {           
        if (instance == null) {                        
            synchronized (DoubleCheckedLocking.class) {  
                if (instance == null)                 
                    instance = new Instance();        
            }                                     
        }                                                
        return instance;                              
    }                                          
}        

在DCL没添加Volatile的版本中,在new Instance()该句中会出现问题,由于new Instance()不是一个原子操作,其操作分为如下过程:

  1. 为Instance对象初始化内存空间.
  2. 初始化Instance对象.
  3. 将Instance对象赋值给instance引用.

由于重排序的存在,编译器可以将2,3顺序进行重排序优化:

  1. 为Instance对象初始化内存空间.
  2. 将Instance对象赋值给instance引用.
  3. 初始化Instance对象.

当线程A再进行new Instance()时候,此时正好执行到第2个步骤,这时候线程B进行判断instance是否为null,发现instance引用不为空,那么就直接返回了,然而线程A还没初始化Instance对象,这就造成了线程B引用了一个未初始化的引用,那么自然会有问题。解决方案就是为instance变量添加volatile关键字,保证禁止指令的重排序,程序就正确了。

关于DCL更详细的内容可以阅读以下这篇文章

最后总结一下Volatile使用的场景:

  • 对变量的写入操作不依赖变量当前值,或者你能保证只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件。
  • 在访问变量时候不需要加锁。

Synchronized

Java中最常见到的同步机制就是Synchronized关键字了,一般情况下,如果对性能的要求不是那么的苛刻,通过Sychronized关键字基本上能够解决所有的线程同步问题。一般使用Synchronized方式有如下几种:

  • 在静态方法中添加Synchronized
  • 在实例方法中添加Synchronized
  • 对某个对象添加Synchronized
  • 对Class添加Synchronized

在静态方法中添加Synchronized的方式和对Class添加Synchronized的本质上是一样的,都是是持有对应的class的锁,示例如下:

public class Test{
    private static int num=2;

    public static void main(String[] args){
        
    }
    public static synchronized void increaseNum(){
        num++;
        System.out.println("调用increaseNum,当前值为:"+num);
    }

    public void increseNum2(){
        synchronized(Test.class){
            num++;
             System.out.println("调用increseNum2,当前值为:"+num);
        }
    }
}


在实例方法中添加Synchronized本质上是持有了当前对象实例的锁,示例代码如下:

public synchronized void increseNum3(){
            num++;
            System.out.println("调用increseNum3,当前值为:"+num);
        
    }

对某个对象添加Synchronized本质上是对持有了当前对象的锁,示例代码如下:

 public  void increseNum4(){
        synchronized (object) {
            num++;
            System.out.println("调用increseNum4,当前值为:"+num);
        }
    }

上面代码中持有了object对象的锁。

Synchronized称之为互斥锁,使用Synchronized能够保证代码段的可见性和原子性,多线程操作中在某一个线程A获得互斥锁的时候,其他线程只能等待而阻塞等待A的执行完毕后再竞争锁资源。除此之外,使用Synchronized时候具备了可重入性,即一个线程获取了互斥锁之后,该线程其他的声明了Synchronized的,如果被调用了,并且是同一个锁的代码段,则是不需要阻塞,能够一并执行的。示例代码如下:

    public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("调用increseNum4,当前值为:"+num);
        }
    }
    public  void increseNum5(){
        synchronized (object) {
            num++;
            System.out.println("调用increseNum5,当前值为:"+num);
        }
    }

可以看到,在increseNum4()方法中我们是有了object对象的锁,其内部中调用了increseNum5()方法,由于increseNum5()中持有相同的object对象锁,所以方法可以等同理解为:

  public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("调用increseNum4,当前值为:"+num);
        }
    }
    public  void increseNum5(){
            num++;
            System.out.println("调用increseNum5,当前值为:"+num);
    }

如果我们修改increseNum5()中的Synchronized的修饰,改成如下:

  public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("调用increseNum4,当前值为:"+num);
        }
    }
    public synchronized  void increseNum5(){
            num++;
            System.out.println("调用increseNum5,当前值为:"+num);
    }

那么由于上述两个方法持有不同的锁,如果increseNum5()不被其他线程使用锁定,那么能够正常执行;反之,increseNum4()方法必须等到increseNum5()的线程执行完毕后释放对应的锁后才能够继续执行代码段。

上篇文章Java并发编程学习二中讲述了底层中JVM针对工作内存与主存的8种交互操作时讲述了一个规则:**一个变量在同一时刻只允许一条线程进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。**lock跟unlock操作我们无法直接操作,取而代之的是关键字monitorenter和monitorexit,这个也在上篇文章中举例说过了,这里也不过多叙述。

Java中的同步实现跟操作系统中的管程(监视器,monitor)有关,管程是操作系统实现同步的重要基础概念。关于对应的介绍可以看下这个维基百科的[介绍](https://zh.wikipedia.org/wiki/%E7%9B%A3%E8%A6%96%E5%99%A8_(%E7%A8%8B%E5%BA%8F%E5%90%8C%E6%AD%A5%E5%8C%96)。关于更加深入的知识点,可以仔细阅读这篇文章,这里对底层Synchronized实现做个总结:

  • 通过在方法中添加Synchronized关键字实现方法同步的,该方法在常量池结构中会标记上ACC_SYNCHRONIZED用于表示隐式同步,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
  • 通过Synchronized关键字修饰代码块的,在字节码中会添加monitorenter 和 monitorexit 指令保证同步操作,其中其中monitorenter指令指向同步代码块的开始位置,而monitorexit表示同步代码块结束的位置(异常和非异常,所以字节码中一个monitorenter都会对应两个monitorexit)。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,当一个线程A持有了monitor后,会在对应对象的对象头之间记录持有的信息,其他线程要获取时候,则会阻塞。当monitorexit执行后,A解除持有monitor,其他线程则继续竞争锁资源。

Lock和ReentrantLock

在Java5.0之前只有Synchronized和Volatile使用,在5.0之后增加了Lock接口,能够实现Synchronized的所有工作,并且除此之外拥有Synchronized不具有的如下特性:

  • 调用更灵活,需要主动申请/释放锁。
  • 提供中断操作。(Synchronized是不响应中断的)
  • 提供超时检测操作。(Synchronized是不提供的)

总而言之,Lock接口比Synchronized更加灵活的控制空间,当Synchronized不能满足我们的需求的时候,可以尝试的考虑使用该接口的实现类,最常见的实现类就是ReentrantLock了,下面就以ReentrantLock作为Demo例子学习。这里首先先介绍一下Lock接口:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

lock()方法的使用跟Synchronized关键字一致,如果当前monitor没有被占用,则获得monitor,其他线程会一直阻塞,直到调用lock()的线程调用unlock()方法,,示例代码如下:

class ThreadTest {
    private static int num = 1;
    private Lock mLock = new ReentrantLock();

    public void increaseNum() {
        try {
            mLock.lock();
            num++;
            System.out.println(timeStamp2Date() + "  调用increaseNum,当前值为:" + num);
            Thread.sleep(4000);
        } catch (Exception e) {

        } finally {
            mLock.unlock();
        }


    }

    public static String timeStamp2Date() {
        String format = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat sdf = new SimpleDateFormat(format);
        return sdf.format(new Date(System.currentTimeMillis()));
    }


    public void increaseNum2() {
        try {
            mLock.lock();
            num++;
            System.out.println(timeStamp2Date() + "  调用increaseNum2,当前值为:" + num);
        } catch (Exception e) {

        } finally {
            mLock.unlock();
        }
    }
}
//-------------------------------------------------------

fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    val thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread1.start()
    thread2.start()
}
//------------------------------------------------
//输出结果
2018-11-19 16:06:10  调用increaseNum,当前值为:2
2018-11-19 16:06:14  调用increseNum2,当前值为:3

increaseNum()中模拟了4秒的耗时操作,可以看到在结果中increaseNum2()确实等待了4秒左右的时间才进行了调用,调用的方式跟Synchronized如出一辙,只不过增加了手动释放的代码。

接下来看看tryLock方法:

  • tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待
  • tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

接下来还是代码测试,首先测试一下传递无参的:


    public void increaseNum() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  调用increaseNum,当前值为:" + num);
                Thread.sleep(4000);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }

        } else {
            System.out.println(timeStamp2Date() + "  increaseNum 获取锁失败");
        }
    }

    public static String timeStamp2Date() {
        String format = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat sdf = new SimpleDateFormat(format);
        return sdf.format(new Date(System.currentTimeMillis()));
    }


    public void increaseNum2() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  调用increaseNum2,当前值为:" + num);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }
        } else {
            System.out.println(timeStamp2Date() + "  increaseNum2 获取锁失败");
        }
    }
	
	------------------------------------------------
	fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    var thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread2.start()
    thread1.start()
    Thread.sleep(5000)
    thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread2.start()
}
//输出结果
2018-11-19 16:37:09  increaseNum 获取锁失败
2018-11-19 16:37:09  调用increaseNum2,当前值为:2
2018-11-19 16:37:14  调用increaseNum2,当前值为:3

接着测试有形参的:

public void increaseNum2(int time) {
        try {
            if (mLock.tryLock(time, TimeUnit.SECONDS)) {
                try {
                    num++;
                    System.out.println(timeStamp2Date() + "  调用increaseNum2,当前值为:" + num);
                } catch (Exception e) {

                } finally {
                    mLock.unlock();
                }
            } else {
                System.out.println(timeStamp2Date() + "  increaseNum2 获取锁失败");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
------------------------------------------------------
fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    var thread2 = Thread(Runnable {
        threadTest.increaseNum2(2)
        threadTest.increaseNum2(4)
    })
    thread1.start()
    thread2.start()
}
//输出结果
2018-11-19 16:43:46  调用increaseNum,当前值为:2
2018-11-19 16:43:48  increaseNum2 获取锁失败
2018-11-19 16:43:50  调用increaseNum2,当前值为:3

第一次调用increaseNum2()的时候由于在2秒的时间内increaseNum()还没有释放掉锁,所以获取锁失败;接着第二次调用increaseNum2()的时候,锁已经释放了,所以正常获取到。

除此之外,通过调用tryLock(long time, TimeUnit unit)方法,能够抛出InterruptedException异常,所以能够正常响应中断操作,即thread.interrupt(),这是Synchronized无法做到的。

与上面方法相同的是lockInterruptibly()也能够正常响应中断操作,方法的描述如下(摘抄来自该篇文章):

  • 请求锁,除非当前线程被中断。
  • 如果没有其他线程持有锁,则当前线程获取到锁,并为锁计数加1,并且立即返回。
  • 如果当前线程已经持有锁,则为锁计数加1,并立即返回。
  • 如果其他线程持有锁,则当前线程将处于不可用状态以达到于线程调度目的,并且休眠直到下面两个事件中的一个发生:
    1. 当前线程获取到锁。
    2. 其他线程中断当前线程。
  • 如果当前线程获取到锁,则将锁计数设置为1。
  • 如果当前线程在方法条目上设置了中断状态或者在请求锁的时候被中断,将抛出中断异常。

关于这个方法的用法和理解就比较复杂了,lockInterruptibly()本身抛出InterruptedException异常,可以类比Thread.sleep()方法,这样就比较好理解了。下面简单给个Demo测试一下:

 public void increaseNum3() {
        boolean flag = false;
        try {
            mLock.lockInterruptibly();
            flag = true;
        } catch (InterruptedException e) {
            System.out.println("中断发生");
        } finally {
            if (flag) {
                mLock.unlock();
            }
        }
    }
    public void increaseNum() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  调用increaseNum,当前值为:" + num);
                Thread.sleep(4000);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }

        } else {
            System.out.println(timeStamp2Date() + "  increaseNum 获取锁失败");
        }
    }
----------------------------------------------------------------
fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread2 = Thread(Runnable {
        threadTest.increaseNum()
    })
    thread2.start()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum3()
    })
    thread1.start()
    Thread.sleep(2000)
    thread1.interrupt()
}
//结果
2018-11-19 17:25:39  调用increaseNum,当前值为:2
中断发生

上述代码thread2在increaseNum()方法中获取到了mLock的锁,所以在thread1调用increaseNum3()时候阻塞了,过了两秒后由于在主线程调用了thread1.interrupt(),所以increaseNum3()中抛出了异常,打印出了中断发生的log。这里只是简单验证了一下一种情况,更多种可以自主测试一下。


wait/notify

最后一个就是wait/notify机制了,wai()方法介绍如下:

  • wait()方法的作用是将当前运行的线程挂起(即让其进入阻塞状态),直到notify或notifyAll方法来唤醒线程.
  • wait(long timeout),该方法与wait()方法类似,唯一的区别就是在指定时间内,如果没有notify或notifAll方法的唤醒,也会自动唤醒。

wait方法是一个本地方法,其底层也是通过monitor对象来完成的,所以我们使用wait/notify机制时候必须跟Synchronized一起使用。除了这个,在线程的概念以及使用文章中还说过:

这里需要区分sleep和wait的区别,wait和notify方法跟sychronized关键字一起配套使用,wait()方法在进入等待状态的时候,这个时候会让度出cpu资源让其他线程使用,与sleep()不同的是,这个时候wait()方法是不占有对应的锁的。

在使用wait方法时候,最好使用如下模板:

synchronized (obj) {
     while (<condition does not hold>)
           obj.wait(timeout);
          ... // Perform action appropriate to condition
     }

关于wait/notify的例子,这里就贴一个单生产者-单消费者模型的Demo吧:

    private static final int MAX_NUM = 10;
    private static final Object lock = new Object();
    static ArrayList<String> list = new ArrayList<>();
    public static class ProductThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (true) {
                synchronized (lock) {
                    while (list.size() > MAX_NUM) {

                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    list.add("h");
                    System.out.println(getName() + ": 生产者生产一个元素");
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                }

            }


        }
    }

    public static class ConsumerThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (true) {
                synchronized (lock) {
                    while (list.size() == 0) {

                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    list.remove(0);
                    System.out.println(getName() + ": 消费者消费一个元素");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                }

            }

        }
    }





参考资料

猜你喜欢

转载自my.oschina.net/u/3863980/blog/2878343
今日推荐