Java线程等待唤醒机制(加深理解)
发布日期:2021-07-19 12:30:07 浏览次数:8 分类:技术文章

本文共 27318 字,大约阅读时间需要 91 分钟。

今天看源码的时候遇到这样一个场景,某线程里面的逻辑需要等待异步处理结果返回后才能继续执行。或者说想要把一个异步的操作封装成一个同步的过程。这里就用到了线程等待唤醒机制,下面具体看一下。

等待唤醒机制示例

下面代码是一个简单的线程唤醒机制示例,主要就是在Activity启动的时候初始化并start线程,线程start后会进入等待状态,在onResume方法中执行notify方法唤醒线程。通过这样的方式模拟异步唤醒线程——线程等待唤醒机制。

 

public class ThreadDemo extends AppCompatActivity {    private final static String TAG = ThreadDemo.class.getSimpleName();    private Object mLock = new Object();    private Thread mThread = new Thread(new Runnable() {        @Override        public void run() {            synchronized (mLock) {                Log.i(TAG,"state 1 = " + mThread.getState());                try {                    mLock.wait(10 * 1000);                    Log.i(TAG,"state 2 = " + mThread.getState());                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        }    });    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);        Log.i(TAG,"state 3 = " + mThread.getState());        mThread.start();        Log.i(TAG,"state = 4 " + mThread.getState());    }    @Override    protected void onStart() {        super.onStart();    }    @Override    protected void onResume() {        super.onResume();        synchronized(mLock) {            Log.i(TAG,"state = 5 " + mThread.getState());            mLock.notify();            Log.i(TAG,"state = 6 " + mThread.getState());        }    }    @Override    protected void onPause() {        super.onPause();        Log.i(TAG,"state = 7 " + mThread.getState());    }    @Override    protected void onStop() {        super.onStop();    }    @Override    protected void onDestroy() {        super.onDestroy();    }}

Log如下:

09-11 17:31:29.577 32658-32658/com.android.peter.threaddemo I/ThreadDemo: state 3 = NEW09-11 17:31:29.578 32658-32658/com.android.peter.threaddemo I/ThreadDemo: state = 4 RUNNABLE09-11 17:31:29.578 32658-32695/com.android.peter.threaddemo I/ThreadDemo: state 1 = RUNNABLE09-11 17:31:29.588 32658-32658/com.android.peter.threaddemo I/ThreadDemo: state = 5 TIMED_WAITING09-11 17:31:29.588 32658-32658/com.android.peter.threaddemo I/ThreadDemo: state = 6 BLOCKED09-11 17:31:29.588 32658-32695/com.android.peter.threaddemo I/ThreadDemo: state 2 = RUNNABLE09-11 17:31:40.276 32658-32658/com.android.peter.threaddemo I/ThreadDemo: state = 7 TERMINATED

为什么可以这么用

我当时看完后产生一个疑问,就是run方法的synchronized代码块不是被锁住了处于TIMED_WAITING状态了么,按理说同步锁应该没有被释放啊,为什么onResume中的synchronized代码块可以被执行从而唤醒线程。 

这是因为wait是Object类的方法,当被调用时即释放资源也释放锁;而sleep是Thread类的方法,当被调用时只释放资源不释放锁。之前都是用的sleep来使线程进入TIMED_WAITING状态的,所以一直的认知是synchronized代码段执行完毕才会释放锁,然后再执行下一个synchronized代码段。经过本次学习对synchronized同步机制有了新的更深刻的认识。

java线程的状态

下面是Thread类中枚举的六种线程状态,借此机会顺便学习一下。
 

libcore/ojluni/src/main/java/java/lang/Thread.java    public enum State {        /**         * Thread state for a thread which has not yet started.         */        NEW,        /**         * Thread state for a runnable thread.  A thread in the runnable         * state is executing in the Java virtual machine but it may         * be waiting for other resources from the operating system         * such as processor.         */        RUNNABLE,        /**         * Thread state for a thread blocked waiting for a monitor lock.         * A thread in the blocked state is waiting for a monitor lock         * to enter a synchronized block/method or         * reenter a synchronized block/method after calling         * {@link Object#wait() Object.wait}.         */        BLOCKED,        /**         * Thread state for a waiting thread.         * A thread is in the waiting state due to calling one of the         * following methods:         * 
    *
  • {@link Object#wait() Object.wait} with no timeout
  • *
  • {@link #join() Thread.join} with no timeout
  • *
  • {@link LockSupport#park() LockSupport.park}
  • *
* *

A thread in the waiting state is waiting for another thread to * perform a particular action. * * For example, a thread that has called Object.wait() * on an object is waiting for another thread to call * Object.notify() or Object.notifyAll() on * that object. A thread that has called Thread.join() * is waiting for a specified thread to terminate. */ WAITING, /** * Thread state for a waiting thread with a specified waiting time. * A thread is in the timed waiting state due to calling one of * the following methods with a specified positive waiting time: *

    *
  • {@link #sleep Thread.sleep}
  • *
  • {@link Object#wait(long) Object.wait} with timeout
  • *
  • {@link #join(long) Thread.join} with timeout
  • *
  • {@link LockSupport#parkNanos LockSupport.parkNanos}
  • *
  • {@link LockSupport#parkUntil LockSupport.parkUntil}
  • *
*/ TIMED_WAITING, /** * Thread state for a terminated thread. * The thread has completed execution. */ TERMINATED; }

结合上面代码运行的log来理解一下线程的各个状态。

  • NEW(初始化状态) 

线程通过new初始化完成到调用start方法前都处于等待状态。

  • RUNNABLE(可执行状态) 

线程执行start方法后就处于可以行状态。

  • BLOCKED(阻塞状态) 

notify方法被调用后线程被唤醒,但是这时notify的synchronized代码段并没有执行完,同步锁没有被释放,所以线程处于BLOCKED状态。直到notify的synchronized代码段执行完毕锁被释放,线程才回到wait所在的synchronized代码段继续执行。

  • WAITING(等待状态) 

调用sleep或是wait方法后线程处于WAITING状态,等待被唤醒。

  • TIMED_WAITING(等待超时状态) 

调用sleep或是wait方法后线程处于TIMED_WAITING状态,等待被唤醒或时间超时自动唤醒。

  • TERMINATED(终止状态) 

在run方法结束后线程处于结束状态。

线程的状态图

1. 初始状态

实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

2.1. 就绪状态

就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。

调用线程的start()方法,此线程进入就绪状态。
当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
锁池里的线程拿到对象锁后,进入就绪状态。
2.2. 运行中状态

线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。

3. 阻塞状态

阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

4. 等待

处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

5. 超时等待

处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

6. 终止状态

  1. 当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
  2. 在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

等待队列

  • 调用obj的wait(), notify()方法前,必须获得obj锁,也就是必须写在synchronized(obj) 代码段内。
  • 与等待队列相关的步骤和图
     

  1. 线程1获取对象A的锁,正在使用对象A。
  2. 线程1调用对象A的wait()方法。
  3. 线程1释放对象A的锁,并马上进入等待队列。
  4. 锁池里面的对象争抢对象A的锁。
  5. 线程5获得对象A的锁,进入synchronized块,使用对象A。
  6. 线程5调用对象A的notifyAll()方法,唤醒所有线程,所有线程进入同步队列。若线程5调用对象A的notify()方法,则唤醒一个线程,不知道会唤醒谁,被唤醒的那个线程进入同步队列。
  7. notifyAll()方法所在synchronized结束,线程5释放对象A的锁。
  8. 同步队列的线程争抢对象锁,但线程1什么时候能抢到就不知道了。 

同步队列状态

  • 当前线程想调用对象A的同步方法时,发现对象A的锁被别的线程占有,此时当前线程进入同步队列。简言之,同步队列里面放的都是想争夺对象锁的线程。
  • 当一个线程1被另外一个线程2唤醒时,1线程进入同步队列,去争夺对象锁。
  • 同步队列是在同步的环境下才有的概念,一个对象对应一个同步队列。
  • 线程等待时间到了或被notify/notifyAll唤醒后,会进入同步队列竞争锁,如果获得锁,进入RUNNABLE状态,否则进入BLOCKED状态等待获取锁。

几个方法的比较

  1. Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
  2. Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。
  3. thread.join()/thread.join(long millis),当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。
  4. obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
  5. obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
  6. LockSupport.park()/LockSupport.parkNanos(long nanos),LockSupport.parkUntil(long deadlines), 当前线程进入WAITING/TIMED_WAITING状态。对比wait方法,不需要获得锁就可以让线程进入WAITING/TIMED_WAITING状态,需要通过LockSupport.unpark(Thread thread)唤醒。

sleep()方法与yield()方法区别:

  • sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程优先级。yield()方法只会给同优先级或更高优先级线程执行机会。
  • sleep()方法将当前线程转入阻塞状态,而yield()强制将当前线程转入就绪状态,因此完全可能某个线程调用yield()后立即再次获得CPU资源。
  • sleep()方法申明抛出InterruptException异常,要么捕捉要么显示抛出,而yield()没有申明抛出任何异常。
  • sleep()比yield()有更好的移植性,不建议yield()控制并发线程执行。

 

疑问

等待队列里许许多多的线程都wait()在一个对象上,此时某一线程调用了对象的notify()方法,那唤醒的到底是哪个线程?随机?队列FIFO?or sth else?Java文档就简单的写了句:选择是任意性的(The choice is arbitrary and occurs at the discretion of the implementation)。

 

线程的底层实现

在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。

这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器(Scheduler)进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。

由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图 

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么就可以并行执行多个线程任务。


延伸:Java并发编程——线程同步和等待唤醒机制

原文:

线程安全是并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要原因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。 

因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式有个名称叫互斥锁,即能达到互斥访问目的的锁。

线程同步

synchronized

在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能),这点确实也是很重要的。

synchronized 修饰方法

方法可以是实例方法,也可以是静态方法。

 

public class AccountingSync implements Runnable{    //共享资源(临界资源)    static int i=0;    /**     * synchronized 修饰实例方法     */    public synchronized void increase(){        i++;    }    @Override    public void run() {        for(int j=0;j<1000000;j++){            increase();        }    public static void main(String[] args) throws InterruptedException {        AccountingSync instance=new AccountingSync();        Thread t1=new Thread(instance);        Thread t2=new Thread(instance);        t1.start();        t2.start();        t1.join();        t2.join();        System.out.println(i);    }    /**     * 输出结果:     * 2000000     */}

上面synchronized修饰的是实例方法increase(),这样的情况下,当前线程的锁便是实例对象instance,注意Java中的线程同步锁可以是任意对象。

当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁。但其他线程还是可以访问该实例对象的其他非synchronized方法。

如果我们改下代码:

//new新实例Thread t1=new Thread(new AccountingSync());Thread t2=new Thread(new AccountingSync());

虽然我们使用synchronized修饰了increase()方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。解决这种困境的方式是将synchronized作用于静态的increase()方法,这样对象锁就是当前类对象,无论创建多少个实例对象,但对于类的对象有且只有一个,所以在这样的情况下对象锁就是唯一的。

 

public class AccountingSync implements Runnable{    static int i=0;    /**     * 作用于静态方法,锁是当前class对象,也就是     * AccountingSync类对应的class对象     */    public static synchronized void increase(){        i++;    }    @Override    public void run() {        for(int j=0;j<1000000;j++){            increase();        }    }    public static void main(String[] args) throws InterruptedException {        //new新实例        Thread t1=new Thread(new AccountingSync());        Thread t2=new Thread(new AccountingSync());        t1.start();        t2.start();        t1.join();        t2.join();        System.out.println(i);    }}

synchronized 同步代码块

除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块:

public class AccountingSync implements Runnable{    static AccountingSync instance=new AccountingSync();    static int i=0;    @Override    public void run() {        //省略其他耗时操作....        //使用同步代码块对变量i进行同步操作,锁对象为instance        synchronized(instance){            for(int j=0;j<1000000;j++){                 i++;              }        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1=new Thread(instance);        Thread t2=new Thread(instance);        t1.start();t2.start();        t1.join();t2.join();        System.out.println(i);    }}

当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁,如下代码:

//this,当前实例对象锁synchronized(this){    for(int j=0;j<1000000;j++){        i++;    }}//class对象锁synchronized(AccountingSync.class){    for(int j=0;j<1000000;j++){        i++;    }}

在Java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

Lock

synchronized属于隐式锁,即锁的持有与释放都是隐式的,我们无需干预,接下来我们讲讲显式锁,即锁的持有和释放都必须由我们手动编写。在Java 1.5中,官方在concurrent并发包中加入了Lock接口,该接口中提供了lock()方法和unLock()方法对显式加锁和显式释放锁操作进行支持,简单了解一下代码编写,如下:

 

Lock lock = new ReentrantLock();lock.lock();try{    //临界区......}finally{    lock.unlock();}

正如代码所示(ReentrantLock是Lock的实现类),当前线程使用lock()与unlock()对临界区进行包围,其他线程由于无法持有锁将无法进入临界区直到当前线程释放锁,注意unlock()操作必须在finally代码块中,这样可以确保即使临界区执行抛出异常,线程最终也能正常释放锁,Lock接口还提供了锁以下相关方法:

 

public interface Lock {    //加锁    void lock();    //解锁    void unlock();    //可中断获取锁,与lock()不同之处在于可响应中断操作,即在获    //取锁的过程中可中断,注意synchronized在获取锁时是不可中断的    void lockInterruptibly() throws InterruptedException;    //尝试非阻塞获取锁,调用该方法后立即返回结果,如果能够获取则返回true,否则返回false    boolean tryLock();    //根据传入的时间段获取锁,在指定时间内没有获取锁则返回false,如果在指定时间内当前线程未被中并断获取到锁则返回true    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;    //获取等待通知组件,该组件与当前锁绑定,当前线程只有获得了锁    //才能调用该组件的wait()方法,而调用后,当前线程将释放锁。    Condition newCondition();}

可见Lock对象锁还提供了synchronized所不具备的其他同步特性,如等待可中断锁的获取(synchronized在等待获取锁时是不可中断的),超时中断锁的获取,可实现公平锁,等待唤醒机制的多条件变量Condition等,这也使得Lock锁在使用上具有更大的灵活性。下面进一步分析Lock的实现类重入锁ReetrantLock。

重入锁ReetrantLock,JDK 1.5新增的类,实现了Lock接口,作用与synchronized关键字相当,但比synchronized更加灵活。ReetrantLock本身也是一种支持重进入的锁,即该锁可以支持一个线程对资源重复加锁,同时也支持公平锁与非公平锁。所谓的公平与非公平指的是在请求先后顺序上,先对锁进行请求的就一定先获取到锁,那么这就是公平锁,反之,如果对于锁的获取并没有时间上的先后顺序,如后请求的线程可能先获取到锁,这就是非公平锁,一般而言非,非公平锁机制的效率往往会胜过公平锁的机制,但在某些场景下,可能更注重时间先后顺序,那么公平锁自然是很好的选择。

 

public class ReenterLock implements Runnable{    public static ReentrantLock lock=new ReentrantLock();    public static int i=0;    @Override    public void run() {        for(int j=0;j<10000000;j++){            //支持重入锁            lock.lock();            lock.lock();            try{                i++;            }finally{                //执行两次解锁                lock.unlock();                lock.unlock();            }        }    }    public static void main(String[] args) throws InterruptedException {        ReenterLock tl=new ReenterLock();        Thread t1=new Thread(tl);        Thread t2=new Thread(tl);        t1.start();        t2.start();        t1.join();        t2.join();        //输出结果:20000000        System.out.println(i);    }}

等待唤醒机制

进入临界区时,却发现在某一个条件满足之后,它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程,条件对象又称作条件变量。

synchronized

synchronized等待唤醒机制主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常。
 

synchronized (obj) {       obj.wait();       obj.notify();       obj.notifyAll();          }

需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被阻塞,wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。

notify/notifyAll方法只是解除了等待线程的阻塞,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。

假设一个场景我们需要用银行转账:

 

public class Bank {private double[] accounts;    //我们首先写了银行的类,它的构造函数需要传入账户数量和账户金额    public Bank(int n,double initialBalance){        accounts=new double[n];        for (int i=0;i

Lock

Lock等待唤醒机制与synchronized有点不同,需要用到Condition条件对象。一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象,调用条件对象的signal/signalAll和await方法。同样这三个方法必须被Lock包住。
 

Lock lock = new ReentrantLock();Condition condition = lock.newCondition();lock.lock();try{     condition.await();     condition.signal();     condition.signalAll();}finally{    lock.unlock();}

使用Lock等待唤醒机制修改上面的例子:

public class Bank {private double[] accounts;    private Lock bankLock;    private Condition condition;    //构造函数    public Bank(int n,double initialBalance){        accounts=new double[n];        //得到锁        bankLock=new ReentrantLock();        //得到条件对象        condition=bankLock.newCondition();        for (int i=0;i

一旦一个线程调用了await方法,它就会进入该条件的等待集。当锁可用时,该线程不能马上解锁,相反他处于阻塞状态,直到另一个线程调用了同一个条件上的signalAll方法时为止。当另一个线程转账给我们此前的转账方时,需要调用condition.signalAll();该调用会重新激活因为这一条件而等待的所有线程。

当一个线程调用了await方法他没法重新激活自身,并寄希望于其他线程来调用signalAll方法来激活自身,如果没有其他线程来激活等待的线程,那么就会产生死锁现象,如果所有的其他线程都被阻塞,最后一个活动线程在解除其他线程阻塞状态前调用await,那么它也被阻塞,就没有任何线程可以解除其他线程的阻塞,程序就被挂起了。

当调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程释放锁后,通过竞争实现对对象的访问。还有一个方法是signal,它则是随机解除某个线程的阻塞。

Lock相比synchronized优劣

优势: 
Lock对象锁提供了synchronized所不具备的同步特性

  • 等待可中断锁的获取 

等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。

  • 可实现公平锁 

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。

  • 等待唤醒机制的多条件变量Condition 

锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法只能实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

Lock可通过多个Condition实例对象建立更加精细的线程控制。使用condition.signalAll(); 可以指定解除哪些等待线程的阻塞,是通过condition关联的。而notifyAll只会解除所有等待线程的阻塞。

如下面例子:银行账户只能有取款和存款操作,每次取款和存款金额必须相同,当账户有钱时只允许取款,不允许存款,账户没钱时只允许存款,不允许取款。

 

public class Bank {    private double[] accounts;    private amount;    private Lock bankLock;    private Condition drawCondition; //取款条件    private Condition depositeCondition; //存款条件    //构造函数    public Bank(int n,double initialBalance){        accounts=new double[n];        //得到锁        bankLock=new ReentrantLock();        //分别得到条件对象        drawCondition=bankLock.newCondition();        depositeCondition=bankLock.newCondition();        for (int i=0;i
=amount){ //阻塞当前线程,并放弃锁 depositeCondition.await(); } //存款的操作 accounts[to] = accounts[to] + amount; drawCondition.signalAll(); } finally { bankLock.unlock(); } }}

我们通过两个Condition对象单独控制取款线程与存款线程,这样可以避免取款线程在唤醒线程时唤醒的还是取款线程,如果是通过synchronized的等待唤醒机制实现的话,就可能无法避免这种情况,毕竟同一个锁,对于synchronized关键字来说只能有一组等待唤醒队列,而不能像Condition一样,同一个锁拥有多个等待队列。

缺点: 

编程稍复杂。

synchronized底层实现

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步(同步方法)都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下: 

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。
  • 对象头:它实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark 

Word 和 Class Metadata Address 组成,其结构说明如下表:

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间: 

这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor() {    _header       = NULL;    _count        = 0; //记录个数    _waiters      = 0,    _recursions   = 0;    _object       = NULL;    _owner        = NULL; //持有ObjectMonitor对象的线程    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet    _WaitSetLock  = 0 ;    _Responsible  = NULL ;    _succ         = NULL ;    _cxq          = NULL ;    FreeNext      = NULL ;    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表    _SpinFreq     = 0 ;    _SpinClock    = 0 ;    OwnerIsThread = 0 ;  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示: 

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因,下面我们将进一步分析synchronized在字节码层面的具体语义实现。

synchronized同步代码块

现在我们重新定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i,如下:

public class SyncCodeBlock {   public int i;   public void syncTask(){       //同步代码块       synchronized (this){           i++;       }   }}

编译上述代码并使用javap反编译后得到字节码如下:

Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class  Last modified 2017-6-2; size 426 bytes  MD5 checksum c80bc322c87b312de760942820b4fed5  Compiled from "SyncCodeBlock.java"public class com.zejian.concurrencys.SyncCodeBlock  minor version: 0  major version: 52  flags: ACC_PUBLIC, ACC_SUPERConstant pool:  //........省略常量池中数据  //构造函数  public com.zejian.concurrencys.SyncCodeBlock();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=1, locals=1, args_size=1         0: aload_0         1: invokespecial #1                  // Method java/lang/Object."
":()V 4: return LineNumberTable: line 7: 0 //===========主要看看syncTask方法实现================ public void syncTask(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter //注意此处,进入同步方法 4: aload_0 5: dup 6: getfield #2 // Field i:I 9: iconst_1 10: iadd 11: putfield #2 // Field i:I 14: aload_1 15: monitorexit //注意此处,退出同步方法 16: goto 24 19: astore_2 20: aload_1 21: monitorexit //注意此处,退出同步方法 22: aload_2 23: athrow 24: return Exception table: //省略其他字节码.......}SourceFile: "SyncCodeBlock.java"

我们主要关注字节码中的如下代码

3: monitorenter  //进入同步方法//..........省略其他  15: monitorexit   //退出同步方法16: goto          24//.........省略其他21: monitorexit //退出同步方法

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

synchronized同步方法

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

public class SyncMethod {   public int i;   public synchronized void syncTask(){           i++;   }}

使用javap反编译后的字节码如下:

Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class  Last modified 2017-6-2; size 308 bytes  MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94  Compiled from "SyncMethod.java"public class com.zejian.concurrencys.SyncMethod  minor version: 0  major version: 52  flags: ACC_PUBLIC, ACC_SUPERConstant pool;   //省略没必要的字节码  //==================syncTask方法======================  public synchronized void syncTask();    descriptor: ()V    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    Code:      stack=3, locals=1, args_size=1         0: aload_0         1: dup         2: getfield      #2                  // Field i:I         5: iconst_1         6: iadd         7: putfield      #2                  // Field i:I        10: return      LineNumberTable:        line 12: 0        line 13: 10}SourceFile: "SyncMethod.java"

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。

Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
 

public class StringBufferRemoveSync {    public void add(String str1, String str2) {        //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用        //因此sb属于不可能共享的资源,JVM会自动消除内部的锁        StringBuffer sb = new StringBuffer();        sb.append(str1).append(str2);    }    public static void main(String[] args) {        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();        for (int i = 0; i < 10000000; i++) {            rmsync.add("abc", "123");        }    }}

无锁同步

在谈论无锁概念时,总会关联起乐观派与悲观派,对于乐观派而言,他们认为事情总会往好的方向发展,总是认为坏的情况发生的概率特别小,可以无所顾忌地做事,但对于悲观派而已,他们总会认为发展事态如果不及时控制,以后就无法挽回了,即使无法挽回的局面几乎不可能发生。这两种派系映射到并发编程中就如同加锁与无锁的策略,即加锁是一种悲观策略,无锁是一种乐观策略,因为对于加锁的并发程序来说,它们总是认为每次访问共享资源时总会发生冲突,因此必须对每一次数据操作实施加锁策略。而无锁则总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,这项CAS技术就是无锁策略实现的关键,下面我们进一步了解CAS技术的奇妙之处。

CAS

CAS的全称是Compare And Swap 即比较交换,CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。其算法核心思想如下 
执行函数:CAS(V,E,N) 
其包含3个参数

  • V表示要更新的变量
  • E表示预期值
  • N表示新值

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。

由于Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类AtomicInteger,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。

public class Test {    public AtomicInteger inc = new AtomicInteger();    public void increase() {        inc.getAndIncrement();    }    public static void main(String[] args) {        final Test test = new Test();        for(int i=0;i<10;i++){            new Thread(){                public void run() {                    for(int j=0;j<1000;j++)                        test.increase();                };            }.start();        }        while(Thread.activeCount()>1)  //保证前面的线程都执行完            Thread.yield();        System.out.println(test.inc);    }}//输出10000

ABA

尽管CAS看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且CAS从语义上来说并不是完美的,存在这样的一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类比较“鸡肋”,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
 

转载地址:https://blog.csdn.net/jdsjlzx/article/details/98470930 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:Java内存模型及volatile
下一篇:源码分析 — Activity的启动流程

发表评论

最新留言

路过按个爪印,很不错,赞一个!
[***.219.124.196]2024年03月20日 10时06分16秒

关于作者

    喝酒易醉,品茶养心,人生如梦,品茶悟道,何以解忧?唯有杜康!
-- 愿君每日到此一游!

推荐文章

gmat阅读.html,GMAT阅读“Ecoefficiency”文章深度分析 2021-06-24
html5 带图片导航,html5 带声音的导航 2021-06-24
point 如何求elbow_机器人学——实践一(Arm Navigation 理论+代码) 2021-06-24
avs3 mkv格式封装_将你的视频无损封装成MP4,非转码哦! 2021-06-24
java http服务端_HTTP协议经典面试题整理及答案详解 2021-06-24
mysql 递归查找父节点_数据结构与算法—浅显易懂的二叉排序(查找)树 2021-06-24
body里写注释 postman_使用 Postman 做 API 自动化测试 2021-06-24
python3的配置文件类单例实现_Servlet是单例还多例 2021-06-24
写一个饿汉单例模式的例子_看完这篇单例模式,终于敢和面试官对线了 2021-06-24
华为手机的分类有何区别_动画有哪些分类?又有何区别? 2021-06-24
编程迷宫_跟我学编程第十期——迷宫游戏 2021-06-24
一键生成安卓证书_【带壳截图+电影台词 生成器】 2021-06-24
北斗轨迹记录_北斗定位+智慧4G视频校车行业解决方案 2021-06-24
存放哪些内容 项目中vuex_房屋安全鉴定中房屋抗震检测内容有哪些 2021-06-24
extjs的panel怎么自适应高度_Ext Js自适应高度 2021-06-24
ilm 和dlm差异_Oracle 的信息生命周期管理工具(ILM assistant) 2021-06-24
斥候密报_斥候密报《最强王者》三国幕后巾帼之黄月英_吉吉建站手游网 2021-06-24
python的循环控制结构是什么_7.Python控制和循环结构 2021-06-24
python 死循环插曲变量_FishC03 讲:python小插曲之变量和字符串 2021-06-24
八大排序算法总结 2021-06-24