Android进阶学习收获(7~12节)

第07节:Java内存模型与线程

       Java内存模型(Java Memory Model)简称JMM,它所描述的是多线程并发、CPU缓存等方面的内容。在每个线程中,都会有一块内部的工作内存,这块工作内存保存了主内存共享数据的拷贝副本。我们在第一节课中,了解到JVM内存结构中有一块线程独立共享的内存空间----虚拟机栈,所以这里我们会自然而然的将线程工作内存理解为虚拟机栈。实际上这种理解是不正确的,虚拟机栈和线程的工作内存并不是一个概念,在Java线程中并不存在所谓的工作内存,它只是对CPU寄存器和高速缓存的抽象描述

CPU普及:我们知道线程是CPU调度的最小单位,线程中的字节码指令最终都是在CPU中执行的。CPU执行过程中免不了要和各种数据打交道,而Java中所有的数据都是存放在主内存(RAM)当中的。

       随着CPU技术的发展,CPU执行的速度越来越快,但内存的技术并没有太大的变化,所以内存中读取和写入数据的过程和CPU的执行速度比起来会越来越大,也就是上图中箭头部分,CPU对主内存的访问需要等待较长的时间,这样就体现不出CPU超强的运算能力的优势了。

      因此,为了“压榨”处理性能,达到“高并发”的效果,在CPU中添加了高速缓存(cache)来作为缓冲。

      在执行任务时,CPU会先将运算所需要使用的数据复制到高速缓存中,让运算能够快速进行,当运算完成后,再将缓存中的结果刷回(flush back)主内存,这样CPU就不用等待主内存的读写操作了。

缓存一致性问题:

    但问题随之而来,每个处理器都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时,可能导致数据不一致,这就是缓存一致性问题。现在市面上很多手机通常有两个或多个CPU,其中一些CPU还有多核,每个CPU在某一时刻都能运行一个线程,这就意味着,当你的Java程序时多线程,那么就存在多个线程在同一时刻被不同的CPU执行的情况。

指令重排:

    除了缓存一致性问题,还存在另一种硬件问题,也比较重要:为了使CPU内部的运算单元能够尽量被充分利用,处理器可能会对输入的字节码指令进行重排序处理,也就是处理器优化。除了CPU 之外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。

内存模型:

      为了解决执行重排、缓存一致性问题,在Java代码在不同的硬件、不同的操作系统中,输出的结果达到一致,Java虚拟机提出了一套机制---Java内存模型。内存模型是一套内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了CPU多级缓存、CPU优化、指令重排等导致的内存访问问题,从而保证Java程序在各个平台下对内存的反问效果一致。

      在Java内存模型中,我们统一用工作内存来当做CPU中寄存器或高速缓存的抽象,线程之间的共享变量存储在主内存中,每个线程都有一个私有工作内存(类比CPU中的寄存器或者高速缓存),本地工作内存中存储了该线程读/写共享变量的副本。在这套规范中,有一个非常重要的规则---happens-before。

happens-before 先行发生原则

       happens-before 用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。它的定义如下:如果一个操作A  happens-beofre 另一个操作B ,那么操作A的执行结果将对操作B可见。  那么Java中的两个操作如何就算符合happens-before 规则了呢?JMM中定义了以下几种情况是自动符合happens-beofre规则的:

  1. 程序次序规则:在单线程内部,如果一段代码的字节码顺序也隐式符合happens-before 原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见,只是后续逻辑中不一定用到而已。
    int  a= 10;     //  1
    
    b = b + 1;      //   2
    
    //当执行到2处时,a = 10这个结果已经是公之于众的,至于有没有用到a这个结果则不一定,比如上面的代码就没有用到a = 10的结果,说明b对a的结果没有依赖,这样就有可能发生指令重排。
    但如果将代码改为以下规则则不会发生指令重排优化:
    int a = 10;   //  1
    b = b + a;    //  2

     

  2. 锁定规则:无论是单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行unlock操作后才能进行lock操作。

  3. 变量规则:volatile保证了线程的可见性。通俗的讲就是如果一个线程先写了一个volatile变量,然后另外一个线程去读这个变量,那么这个写操作一定是happens-before 读操作的。

  4. 线程启动规则:Thread对象的start()方法先行与发生于此线程的每一个动作。假定线程A在执行的过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改,在线程B开始执行后确保对线程B可见。

  5. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断的代码检测,直到中断事件的发生。

  6. 线程终结规则:线程中所有的操作都发生在线程的终止检测之前,我们可以通过Thread.join()方法结束、Thead.isAlive()的返回值等方法来检测线程是否终止执行。假定线程A在执行的过程中,通过调用ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

  7. 对象终结规则:一个对象的初始化完成发生在它的finalize()方法开始前。此外,happens-before原则还具有传递性:如果操作A happens-before 操作 B,而操作B happens-before 操作 C,则操作A一定happens-before 操作 C。

Java内存模型应用

      上面介绍的happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,根据这个原则,我们能够解决在并发环境下操作之间是否可能存在冲突的所有问题。在此基础上,我们可以通过Java提供的一系列关键字,将我们自己试下的多线程操作“happens-before”化。“happens-before化”就是将本来不符合happens-before原则的某些操作,通过某种手段使它们符合happens-before原则。

总结

本课时我们主要介绍了以下几点:

  • Java内存模型的来源:主要是因为CPU缓存和指令重排等优化会造成多线程程序结果不可控。
  • Java内存模型是什么:本质上它就是一套规范,在这套规范中有一条最重要的happens-before原则。
  • 最后介绍了Java内存模型的使用,其中最简单介绍了两种方式:volatile和synchronized。除了这两种方式,Java还提供了很多关键字来实现happens-before原则。

第08讲:既synchronized,何生ReentrantLock

使用synchronized可以用来修饰以下3个层面:

  1. 修饰实例方法  
        这种情况下的锁对象是当前实例对象,因此只有在同一个实例对象调用以下方法才会产生互斥效果,不同实例之间不会才生互斥效果。如下:
    public class Test{
        public static void main(String[] args){
            Test test = new Test();
            Thread t1 = new Thread(new Runnable(){
                @Override
                public void run(){
                    test.printLog();
                }
            });
    
            Thread t2 = new Thread(new Runnable(){
                @Override
                public void run(){
                    test.printLog();
                }
            });
            t1.start();
            t2.start();
        }
    
        public synchronized void printLog(){
            for(int i = 0 ;i< 5; i++){  
              System.out.println(Thread.currentThead().getName()+""+i);
            }
        }
    }

    执行效果如下:

     

  2. 修饰静态方法
    如果synchronized修饰的是静态方法,则锁对象是当前类的Class对象。因此即使在不同线程中调用不同实例对象,也会有互斥效果。
  3. 修饰代码块
    public class Test{
        private Object lock = new Object();
        public static void main(String[] args){
            Test test = new Test();
            Thread t1 = new Thread(new Runnable(){
                @Override
                public void run(){
                    test.printLog();
                }
            });
    
            Thread t2 = new Thread(new Runnable(){
                @Override
                public void run(){
                    test.printLog();
                }
            });
            t1.start();
            t2.start();
        }
    
        public void printLog(){
            synchronized(lock){
              for(int i = 0 ;i< 5; i++){  
               System.out.println(Thread.currentThead().getName()+""+i);
              }
            }
        }
    }

    synchronized作用于代码块时,锁对象就是跟在后面括号中的对象。任何Object对象都可以看作锁对象。

ReentrantLock

ReentrantLock的使用和synchronized有点不同,它的加锁和解锁都需要手动完成,如下:

public class Test{
    ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args){
        Test test = new Test();
        Thread t1 = new Thread(new Runnable(){
            @Override
            public void run(){
                test.printLog();
            }
        });

        Thread t2 = new Thread(new Runnable(){
            @Override
            public void run(){
                test.printLog();
            }
        });
        t1.start();
        t2.start();
    }

    public void printLog(){
          try{
            lock.lock();
            for(int i = 0 ;i< 5; i++){  
           System.out.println(Thread.currentThead().getName()+""+i);
            }
         }catch(){
            lock.unlock();
        }     
    }
}

公平锁实现

ReentrantLock有一个带参数的构造器,如下:

public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() : new NonfairSync();
}

默认情况下,synchronized和ReentrantLock都是非公平锁。但是ReentrantLock可以通过传入true来创建一个公平锁。所谓公平锁就是通过同步队列来实现多个线程按照申请锁的顺序获取锁。代码如下:

public class Test implements Runnable{
    private int shareNumber = 0;
    //创建公平锁
    private static ReentrantLock lock = new ReentrantLock(true);
    public void run(){
        while(shareNumber < 20){
            lock.lock();
            try{
                shareNumber++;
                System.out.println(Thread.currentThead().getName()+"获得锁,shareNumber is " + shareNumber);
            }finally{
                lock.unlock();
            }
        }
    }

    public static void main(String[] args){
        Test test = new Test();
        Thread t1 = new Thread(test);
        Thread t2 = new Thread(test);
        Thread t3 = new Thread(test);
        t1.start();
        t2.start();
        t3.start();
    }
}

运行效果:

 读写锁(ReentrantReadWriteLock)

       在没有读写锁支持的时候,我们使用Java的等待通知机制来控制读写。就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行。这样做的目的就是使读操作能读到正确的数据,不会出现脏读。

      如果使用concurrent包中的读写锁(ReentrantReadWriteLock)实现上述功能,就需要在读操作时获取读锁,写操作时获取写锁即可。当写锁获取到时,后续的读写锁都会被阻塞,写锁释放之后,所有操作继续执行,这种方式相对于等待通知机制而言,变得简单明了。参考代码如下:
 

public class ReadWriteTest{

    private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
    private static String number = "0";

    public static void main(String[] args) throws InterruptedException{
        Thread t1 = new Thread(new Reader(),"读线程 1");
        Thread t2 = new Thread(new Reader(),"读线程 2");
        Thread t3 = new Thread(new Writer(),"写程序");
        t1.start(); t2.start(); t3.start();
    }
    static class Reader implements Runnable{

        public void run(){
            for(int i = 0;i<=10;i++){
                lock.readLock().lock();
                System.out.println(Thread.currentThread().getName()+"--->Number is" +number);
                lock.readLock().unlock();
            }
        }
    }

    static class Writer implements Runnable{
        public void run(){
            for(int i = 0;i<=7;i+=2){
                try{
                    lock.writeLock().lock();
                    System.println(Thread.currentThread().getName()+"正在写入"+i);
                    number = number.concat("" + i);
                }finally{
                    lock.writeLock().unlock();
                }
            }
        }
    }
}

执行结果如下:

如上图所示,当执行写操作时,读取数据的操作会被阻塞。当写操作执行成功后,读取数据的操作继续执行,并且读取的数据也是最新写入后的实时数据。

总结

      本节主要学习了Java中两个实现同步的方式 synchronized 和 ReentrantLock。其中synchronized使用更简单,加锁和释放锁都是由虚拟机自动完成,而ReentrantLock需要开发者手动去完成。但是很显然ReentrantLock 的使用场景更多,公平锁还有读写锁都可以在复杂的场景中发挥重要作用。

第09讲:Java线程优化 偏向锁、轻量级锁、重量级锁

     Java中的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统帮忙,这就要从用户态转到核心态,因此状态转换需要花费很多的处理器时间。比如如下代码:

private Object lock = new Object();
private int value;

public void setValue(){
    synchronized(this){
        value++;
    }
}

value++因为被关键字修饰,所以会在各个线程间同步执行。但是value++消耗的时间很有可能比线程状态转换消耗的时间还短,所以说synchronized是Java语言中一个重量级的操作。

Synchronized实现原理

 要了解synchronized的原理需要先理清楚两间事情:对象头和Monitor。

  • 对象头    Java对象在内存中的布局分为3部分:对象头、实例数据、对齐填充。当我们在Java代码中,使用new创建一个对象时,JVM会在堆中创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。instanceOopDescription的基类为oopDes类,它的结构如下:
    class oopDesc{
        friend class VMStructs;
        private:
                volatile markOop _mark;
                union _metadata{
                    wideKlassOop _klass;
                    narrowOop _compressed_klass;
                } _metadata;
    }

    其中_mark和_metadata一起组成了对象头。_metadata主要保存了类元数据,重点看下_mark属性,_mark 是markOop类型数据,一般称它为标记字段(Mark Word),其中主要存储了对象的hashCode、分代年龄、锁标志位,是否偏向锁等。一张图来表示32位Java虚拟机的Mark Word 的默认存储结构:
     

    除了Mark Word默认存储结构外,还有如下可能变化的结构:
     

    从上图可以看出,根据“锁标志位”以及“是否为偏向锁”,Java中的锁可以分为以下几种状态:
     

    在Java 6之前,并没有轻量级锁和偏向锁,只有重量级锁,也就是通常所说的synchronized的对象锁,锁标志位为10。从图中的描述可以看出:当锁是重量级锁时,对象头中Mark Word会用30bit来指向一个“互斥量”,这个互斥量就是Monitor。

  • Monitor       可以把Monitor理解为一个同步工具,也可以描述为一种同步机制。实际上,它是保存在对象头中的一个对象。在markOop中有如下代码:
     

    bool has_monitor() const {
        return ((vulue() & monitor_value) != 0);
    }
    ObjectMonitor*monitor() const{
        assert(has_monitor(),"check");
        return (ObjectMonitor*) (value() ^ monitor_value);
    }

    通过monitor()方法创建一个ObjectMonitor对象,而ObjectMonitor就是Java虚拟机中的Monitor的具体实现。因此Java对象中每个对象都会有一个对应的ObjectMonitor对象,这也是Java中所有的Object都可以作为锁对象的原因。

那么ObjectMonitor是如何实现同步机制的呢?首先看下ObjectMonitor的结构:

其中几个比较关键的属性:

       当多个线程同时访问一段代码时,首先会进入_EntryList队列中,当某个线程通过竞争获取到对象的monitor后,monitor会把_owner变量设置为当前线程,同时monitor中的计数器_count加1,即获得对象锁。

       若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

实例演示:比如以下代码通过3个线程分别执行以下同步代码块:

private Object lock = new Object();
public void syncMethod(){
    synchronized(lock){
        //do something
    }
}

锁对象是lock对象,在JVM中会有一个ObjectMonitor对象与之对应,如下图:

分别使用3个线程来执行以上代码块,默认情况下,3个线程都会先进入ObjectMonitor中的EntrySet队列中,如下所示:

 

假设线程2首先通过竞争获得了锁对象,则ObjectMonitor中的Owner指向线程2,并count加1,结果如下:

Owner指向线程2表示它已经成功获得到锁(Monitor)对象,其他线程只能处于阻塞(blocking)状态。如果线程2在执行过程中调用wait()操作,则线程2会被释放锁(Monitor)对象,以便其他线程进入获取锁对象,Owner变量恢复为null,count做减1操作,同时线程2会添加到WaitSet集合,进入等待(waiting)状态并等待被唤醒。然后线程1和线程3再次竞争锁对象,假设线程1获取到锁,如下:

如果线程1在执行过程中铜鼓调用notify操作将线程2唤醒,则当前处于WaitSet中的线程2会被重新添加EntrySet集合中,参与重新竞争锁对象。但是notify操作并不会使线程1释放锁。当1执行完毕之后,同样会释放锁,以便其他线程再次获取到锁对象。

     实际上,ObjectMonitor的同步机制时JVM对操作系统级别的Mutex Lock(互斥锁)的管理过程,其间都会转入操作系统内核态。也就是说synchronized实现锁,在“重量级锁”状态下,当多个线程之间切换上线文时,还是一个比较重量级的操作。

Java虚拟机对synchronized的优化

      从Java 6开始,虚拟机对synchronized关键字做了多方面的优化,主要目的就是,避免ObjectMonitor的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率。其中主要做了以下几个优化:锁自旋、轻量级锁、偏向锁。

  1. 锁自旋
           线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力,所以Java引入了自旋锁的操作。实际上自旋锁在Java 1.4就被引入了,默认关闭,但是可以使用参数-XX:+UseSpinning将其开启。但是从Java 6之后默认开启。
          所谓自旋,就是让线程等待一段时间,不会被立即挂起,看当前持有锁的线程是否会很快释放锁,而所谓的等待就是执行一段无意义的循环即可(自旋)。
        自旋锁也存在一定的缺陷:自旋锁需要占用CPU,如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁止自旋锁。
  2. 轻量级锁
        有时候Java虚拟机中会存在这种情形:对于一块同步代码,虽然会有多个线程去执行,但是这些线程是在不同的时间段交替请求这把锁对象,也就是不存在锁竞争的情况。在这种情况下,锁会保持在轻量级锁的状态,从而避免重量级锁的阻塞和唤醒操作。
        要了解轻量级锁的工作流程,还需要再次看下对象头中的Mark Word。上文中提到,锁的标志位包含几种情况:00代表轻量级锁、01代表无锁(或者偏向锁)、10代表重量级锁、11则跟垃圾回收算法的标记有关。
        当线程执行同步代码时,Java虚拟机会在当前线程的栈帧中开辟一块空间(Lock Record)作为该锁的记录,如下:

    然后Java虚拟机会尝试使用CAS(Compare And Swap)操作,将锁对象的Mark Word拷贝到这块空间中,并且将锁记录中的Owner指向Mark Word。结果如下:
     

        当线程再次执行此同步代码块时,判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这是轻量级锁需要膨胀为重量级锁。
        轻量级锁所适用的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

  3. 偏向锁
           轻量级锁是在没有锁竞争情况下的锁状态,但是在有些时候锁不仅存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获取锁的代价更低引入了偏向锁的概念。
         偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个代码块,不需要再次进行抢占锁和释放锁操作。偏向锁可以通过-XX:+UseBiasedLocking开启或者关闭。 
           偏向锁的具体实现就是在锁对象的对象头中有个TheadId字段,默认情况下这个字段是空的,当第一次获取到锁的时候,就将自身的ThreadId写入锁对象的Mark Word中的ThreadId字段内,将是否偏向锁的状态设置为01.这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需要再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。
          其实偏向锁并不适合所有应用场景,因为一旦出现锁竞争,偏向锁会被撤销,并膨胀成为轻量级锁,而撤销操作时比较重的行为,只有当存在较多不会真正竞争的synchronized块时,才能体现出明显改善;因此实践中,还是需要考虑具体业务场景,并测试后,再决定是否开启/关闭偏向锁。
        对于锁的几种状态转换的源码分析,可以参考:源码分析Java虚拟机中锁膨胀的过程

总结:偏向锁和轻量级锁都是通过自旋等技术避免真正的加锁,而重量级锁才是获取锁和释放锁,重量级锁通过对象内部的监视器实现,其本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本非常高。实际上Java对锁的优化还有“锁消除”,但是“锁消除”是基于Java对象逃逸分析的,如果对此感兴趣,可以查阅Java逃逸分析这篇文章。

添加:类的静态变量是是在类加载的过程中被初始化,并存储在方法区中,而当new一个这个类的对象时,对象和其本地变量存储在堆内存中,而对象的引用存储在Java虚拟机栈中。

第10讲:深入理解AQS和CAS原理

AQS:Abstract Queued Synchronizer ,一般翻译为同步器。它是一套实现多线程同步功能的框架,由大名鼎鼎的Doug Lea操刀设计并开发实现的。AQS在源码中被广泛使用,尤其是在JUC(Java Util Concurrent)中,比如ReentrantLock、Semaphore、CountDownLatch、ThreadPoolExecutor。实际开发中,我们也可以通过自定义AQS来实现各种需求场景。

ReentrantLock 和 AQS的关系

       ReentrantLock并没有直接继承AQS,而是通过其内部抽象类Sync来扩展AQS的功能,然后ReentrantLock中存有一个Sync的全局变量引用。

      Sync 在ReentrantLock有两种实现:NonfairSync 和 FairSync,分别对应非公平锁和公平锁。以非公平锁为例,实现源码如下:

static final class NonfairSync extends Sync{

    final void lock(){

        //通过cas操作来修改state状态,表示争抢锁的操作
        if(compareAndSetState(0,1)){
            //设置当前获得锁状态的线程
            setExclusiveOwnerThread(Thread.currentThread());
        }else{
            acquire(1);  //修改状态失败,尝试去获取锁
        }
    }
    protected final boolean tryAcquire(int acquires){
        return nonfairTryAcquire(acquires);
    }
}

可以看出,在非公平锁的lock()方法中,主要做了如下操作:

  • 如果通过CAS设置变量State(同步状态)成功,表示当前线程获取锁成功,则将当前线程设置为独占线程。
  • 如果通过CAS设置变量State(同步状态)失败,表示当前锁正在被其他线程持有,则进入Acquire方法进行后续处理。

acquire()方法定义在AQS中,是一个比较重要的方法,具体如下:

public final void acquire(int arg){

    if(!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
    selfInterrupt();
}

该方法可以拆解为3个主要步骤:

  • tryAccquire()方法主要目的是尝试获取锁;
  • addWaiter()如果tryAccquire()尝试获取锁失败则调用addWaiter将当前线程添加到等待队列中;
  • accquireQueued()处理加入到队列中的节点,通过自旋去尝试获取锁,根据情况将线程挂起或者取消。

    以上三个方法都被定义在AQS中,但其中tryAccquire()有点特殊,其实现如下:

protected boolean tryAcquire(int arg){

    throw new UnsupportedOperationException();
}

默认情况下直接抛异常,因此需要在子类中复写,也就是说真正的获取锁的逻辑由子类同步器自己实现。一张图表示ReentrantLock.lock()过程:

      上图中可以看出,ReentrantLock执行lock() 的过程中,大部分同步机制的核心逻辑都已经在AQS中实现,ReentrantLock自身只需要某些特定步骤下的方法即可,这种模式叫作模板模式(类比Activity,Activity的生命周期的执行流程都已经在framework中定义好了,子类Activity只要在响应的onCreate、onPause等生命周期方法中提供相应的实现即可)。

      注:不只ReentrantLock,JUC包中其他组件例如CountDownLatch、Semaphor等都是通过一个内部类Sync来继承AQS,然后再内部通过操作Sync来实现同步。这种做法的好处是,将线程控制的逻辑 控制在Sync内部,而对外面向用户提供的接口是自对应锁,这种聚合关系能够很好的解耦两者所关注的逻辑。

AQS核心功能原理分析

首先看下AQS中几个关键属性,如下:

static final class Node{
    ...
}

private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;

代码中展示了AQS中两个比较重要的属性Node和state。

state锁状态:

      当state=0时表示无锁状态;当state>0时,表示已经有线程获取了锁,也就是state=1,如果一个线程多次获得同步锁的时候,state会递增,比如重入5次,那么state=5.而在释放锁的时候,同样需要释放5次直到state=0,其他线程才有资格获取锁。

       state还有一个功能是实现锁的独占模式或者共享模式。

  • 独占模式:只有一个线程能够持有同步锁。在独占模式下,我们可以把state初始设置为0,当某个线程申请锁对象时,需要判断state的值是不是0,如果不是,则意味着其他线程已经持有锁,本线程需要阻塞等待。
  • 共享模式:可以由多个线程持有同步锁。比如某项操作我们允许10个线程同时进行,超过这个数量就需要阻塞等待。那么只需要在线程申请对象时判断state的值是否小于10,如果小于就将state加1后继续执行同步语句,如果等于10,则本线程需要阻塞等待。

Node双端队列节点

     Node是一个先进先出的双端队列,并且是等待队列,当多个线程争用资源被阻塞时会进入此队列。这个队列是AQS实现多线程同步的核心。

在AQS中有两个Node的指针,分别指向队列的head和tail。Node的主要结构如下:

static final class Node {
    //该等待同步的节点处于共享模式
    static final Node SHARED = new Node();
    //该等待同步的节点处于独占式
    static final Node EXCLUSIVE = null;
    //Node中的线程状态,这个和state是不一样的:有1,0,-1,-2,-3五个值
    volatile int waitStatus;
    static final int CANCELLED = 1;
    static final int SIGNAL  = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    volatile Node prev;   //前驱节点
    volatile Node next;   //后继节点
    volatile Thread thread;  //等待锁的线程
    ...
}

获取锁失败后续流程分析

      前面我们提到在ReentrantLock.lock()阶段,在acquire()方法中会先后调用tryAccquire、addWaiter、accquireQueued这3个方法处理。tryAccquire在ReentrantLock中被复写并实现,如果返回true说明成功获取锁,就继续执行同步代码语句。可以如果tryAcquire返回false,也就是当前锁对象被其他线程所持有,那么当前线程会被AQS如何处理呢?

 首先当前获取锁失败的线程会被添加到一个等待队列的末端,具体源码如下:

private Node addWaiter(Node mode){
    //把当前线程封装到一个新的Node
    Node node = new Node(Thread.currentThread(),mode); 
    Node pred = tail;
    if(pred != null){
        //将node插入队列
        node.prev = pred;
        if(compareAndSetTail(pred,node)){
            //CAS替换当前尾部,成功则返回
            pred.next = node;
            return node;
        }
    }
    enq(node);   //插入队列失败,进入enq自旋重试入队
    return node;
}

/**
 * 插入节点到队列中,如果队列未初始化则初始化,然后再插入
 */
private Node enq(final Node node){
    for(;;){
        Node t = tail;
        if(t==null){
            //如果队列从未被初始化,则需要初始化一个空的node
            if(compareAndSetHead(new Node()))
                tail = head;
        }else{
            node.prev = t;
            if(compareAndSetTail(t,node)){
                t.next = node;
                return t;
            }
        }
    }
}

    经过addWaiter方法后,此时线程以Node的方式被加入到队列的末端,但是线程还没有被执行阻塞操作,真正的阻塞操作在下面的accquireQueued方法中判断执行。

    在AccquireQueued方法中并不会立刻挂起该节点下中线程,因此在插入节点的过程功中,之前持有锁的线程可能已经执行完毕并释放锁,所以这里使用自旋再次去获取锁,如果自旋操作还是没有获取到锁,那么就将线程挂起(阻塞)。

获取锁的流程总结如下:

  1. AQS的模板方法accquire通过调用子类自定义的tryAccquire获取锁;
  2. 如果获取失败,通过addWaiter方法将线程构造成Node节点插入到同步队列尾部;
  3. 在AccquireQueued方法中以自旋的方法获取锁,如果失败则判断是否需要将当前线程阻塞,如果需要阻塞则最终执行LockSupport(Unsafe)中的native API来实现线程阻塞。

释放锁流程分析

       在上面加锁阶段被阻塞的线程需要被唤醒后才可以重新执行,AQS是何时尝试唤醒等待队列中被阻塞的线程呢?同加锁过程一样,释放锁需要从ReentrantLock.unlock()方法开始:

public void unlock(){
    sync.release(1);
}

public final boolean release(int arg){
    if(tryRelease(arg)){
        Node h = head;
        if(h!=null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

tryRealse方法尝试释放锁,如果成功最终会调用AQS中的unparkSuccessor方法来实现锁的释放操作。unparkSuccessor具体实现如下:

private void unparkSuccessor(Node node){
    //获取头结点waitStatus
    int ws = node.waitStatus;
    if(ws <0 )
        compareAndSetWaitStatus(node,ws,0);
    //获取当前节点(实际是head节点)的下一个节点
    Node s = node.next;
    //如果下个节点是null或者下个节点是CANCEL状态,就找到队列最开始的非CANCEL的节点
    if(s==null || s.waitStatus > 0){
        s = null;
        //就从尾部节点开始找,到队首,找到队列第一个waitStatus<0的节点
        for(Node t = tail ; t!=null && t!=node ; t = t.prev){
            if(t.waitStatus<=0)
                s = t;
        }
    }
    //如果当前节点的下一个节点不为空,而且状态<=0,就把当前节点unpark
    if(s!=null)
        LockSupport.unpark(s.thread);
}

解释说明:首先获取当前节点(实际上传入的是head节点)的状态,如果head节点的下一个节点是null,或者下一个节点的状态为CANCEL,则从等待队列的尾部开始遍历,直到寻找第一个waitStatus小于0的节点。如果最终遍历的节点不为null,再调用LockSupport方法,调用底层方法唤醒线程。至此,线程被唤醒的时机也分析完毕。

CAS  全称Compare And Swap ,译为比较和替换,是一种通过硬件实现并发安全的常用技术,底层通过利用CPU的CAS指令缓存或总线加锁的方式来实现多个处理器之间的原子操作。

总结:

      AQS是一套框架,在框架内部已经封装好了大部分同步需要的逻辑,在AQS内部维护了一个状态指示器state和一个等待队列Node,而通过state的操作又分为两种:独占式和共享式,这就导致AQS有两种不同的实现:独占锁(ReentrantLock)和分享锁(CountDownLatch、读写锁等),本节从独占锁的角度分析了AQS的加锁和释放锁的流程。理解其原理,才能在其框架上扩展,其中有几个可能需要子类同步器实现的方法,如下:

  • lock()。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。

第11讲:线程池之刨根问底

      通过学习synchronized原理,我们知道Java中线程的切换以及上下文切换是比较消耗性能的,因此引入了偏向锁、轻量级锁等优化技术,目的就是减少用户态和核心态的切换频率。但线程的创建和销毁也是非常损耗性能的,因为线程创建时需要开辟虚拟机栈、本地方法栈、程序计数器等私有的内存空间,在线程销毁时需要回收这些系统资源,频繁第创建和销毁会浪费大量的资源,而通过复用线程可以更好地管理和协调线程的工作。

线程池主要解决两个问题:

  1. 当执行大量异步任务时线程池能够提供很好的性能。
  2. 线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。

线程池体系如下:

  • Executor是线程池最顶层的接口,在Executor中只有一个execute方法,用于执行任务。至于线程的创建、调度等细节由子类实现。
  • ExecutorService继承并拓展了Executor,在ExecutorService内部提供了更全面的任务提交机制以及线程池关闭方法。
  • ThreadPoolExecutor是ExecutorService的默认实现,所谓的线程池机制也大多封装在此类当中。
  • ScheduledExecutorService 继承自ExecutorService,增加了定时任务相关方法。
  • ScheduledThreadPoolExecutor 继承自ThreadPoolExecutor,并实现了ScheduledExecutorService接口。
  • ForkJoinPool 是一种支持任务分解的线程池,一般要配合可分解任务接口ForkJoinTask来使用。

创建线程池

 为了开发者更方便地使用线程池,JDK中提供了一个线程池的工厂类--Executors。在Executors中定义了多个静态方法,用来创建不同配置的线程池,常见有以下几种:

  1. newSingleThreadExecutor()
    创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按先进先出的顺序执行。
    public class CreateSingleThreadExecutor{
        //创建单线程池
        ExecutorService singgleThreadExecutor = new Executors.newSingleThreadExecutor();
        for(int i=0;i<=5;i++){
            final int taskId = i;
            //向线程池中提交任务
            singleThreadExecutor.submit(new Runnable(){
                @Override
                public void run(){
                    System.out.println("线程:"+Thread.currentThread().getName()+"正在执行 task:"+taskId);
                }
            });
        }
    }

    执行结果如下,可以看出所有的task始终是在同一个线程中执行的。

     

 

 

 

 


2.newFixedThreadPool(int nThreads)
   
创建一个固定数目的、可重用的线程池。

public class CreateFixThreadExecutor{
    //创建单线程池
    ExecutorService fixThreadExecutor = new Executors.newFixedTheadPool(3);
    for(int i=0;i<=10;i++){
        final int taskId = i;
        //向线程池中提交任务
        singleThreadExecutor.submit(new Runnable(){
            @Override
            public void run(){
                System.out.println("线程:"+Thread.currentThread().getName()+"正在执行 task:"+taskId);
            }
        });
    }
}

创建了一个固定数量3的线程池,因此虽然向线程池中提交了10个任务,但这10个任务只会被3个线程分配执行,结果如下:

3.newCachedTheadPool

创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

4.newScheduledThreadPool

创建一个定时线程池,支持定时及周期性任务执行。

public class CreateScheduledThreadPool{
    ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
    scheduledThreadPool.scheduleAtFixedRate(new Runnable(){
         @Override
         public void run(){
            Date now = new Date();
            System.out.println("线程:"+Thread.currentThread().getName()+"报时:"+now); 
            
        }
    },500,500,TimeUnit.MILLISECONDS);
    Thread.sleep(5000);
    //使用shutdown关闭定时任务
    scheduledThreadPool.shutdown();
}

上面创建了一个线程数量为2的定时任务线程池,通过ScheduleAtFixedRate方法,指定每隔500毫秒执行一次任务,并且在5秒钟之后通过shutdown方法关闭定时任务。执行效果如下:

线程池的工作原理分析

线程池结构

从图中可以大体看出,在线程池中主要包含以下几个部分:

  1. worker集合:保存所有的核心和非核心线程(类比加工厂的加工机器),其本质是一个HashSet。
    private final HashSet<Worker> workers = new HashSet<>();

     

  2. 等待任务队列:当核心线程的个数达到corePoolSize时,新提交的任务会被先保存在等待队列中,其本质是一个阻塞队列BlockingQueue。

    private final BlockingQueue<Runnable> workQueue; 

     

    3.ctl:是一个AtomicInteger类型,二进制高3位用来标识线程池的状态,低29位用来记录池中线程的数量。获取线程池状态、工作线程数量、修改ctl的方法分别如下:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING,0));

private static int runStateOf(int c){ return c & -CAPACITY; } //计算当前运行状态
private static int workCountOf(int c){return c & CAPACITY;} //计算当前线程数量
private static int ctlOf(int rs ,int wc){ return rs | wc;} //通过状态和线程数生成ctl。

线程池主要有以下几种运行状态:

1.RUNNING:   默认状态,接收新任务并处理排队任务;

2.SHUTDOWN:  不接收新任务,但处理排队任务,调用shutdown()会处于该状态;

3.STOP:      不接收新任务,也不处理排队任务,并中断正在运行的任务,调用shutdownNow()会处于该状态。

4.TIDYING:所有任务都已终止,workCount为零时,线程会转换到TIDYING状态,并将运行terminate()方法;

5.TERMINATED:terminate()运行完成后线程池转为此状态;

参数分析

线程池的构造器如下:

public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectExecutionHandler handler){
        
    if(corePoolSize<0 || maximumPoolSize<corePoolSize || keepAliveTime<0){
        throw new IllegalArgumentException();
    }    
    if(workQueue == null || threadFactory == null || hander == null){
        throw new NullPointerException();
    }
    ......
}
  1. corePoolSize:表示核心线程数;
  2. maximumPoolSize:表示线程池最大能够容纳同时执行的线程数,必须大等于1。
  3. keepAliveTime:表示线程池中的线程空闲时间,当空闲线程达到此值时,线程会被销毁,直到剩下corePoolSize个线程。
  4. unit:用来指定keepAliveTime的时间单位,有MILLISENCONDS、SECONDS、MINUTES、HOURS等。
  5. workQueue:等待队列,BlockingQueue类型。当请求任务数大于corePoolSize时,任务将被缓存在此BlockingQueue中。
  6. threadFactory:线程工厂,线程池中使用它来创建线程,如果传入null,则使用默认工厂类DefaultThreadFactory。
  7. handler:执行拒绝策略的对象。当workQueue满了之后并且活动线程数大于maximumPoolSize的时候,线程池通过该策略处理请求。

注意:当ThreadPoolExecutor的 allowCoreThreadTimeOut设置为true时,核心线程超时也会被销毁。

流程解析

当我们调用execute或者submit提交一个任务到线程池,线程池收到这个任务请求后,有以下几种处理情况:

1.当前线程池中的运行的线程数量还没有达到corePoolSize大小时,线程池会创建一个新线程执行提交的任务,无论之前创建的线程是否处于空闲状态。

2.当先线程池中运行的线程数量达到corePoolSize大小时,线程池会把任务加入到等待队列找那个,直到某一个线程空闲了,线程池会根据我们设置的等待队列规则,从队列中取出一个新的任务执行。

3.如果线程数大于corePoolSize数量但是还没有达到最大线程数maximumPoolSize,并且等待队列已满,则线程池会创建新的线程来执行任务。

4.如果提交的任务,无法被核心线程直接执行,又无法加入等待队列,又无法创建“非核心线程”直接执行,线程池将根据拒绝处理器定义的策略处理这个任务。比如在ThreadPoolExecutor中,如果你没有为线程池设置RejectedExceptionHandler。这时线程池会抛出RejectedException异常,即线程池拒绝接受这个任务。
     拒绝策略是线程池的一种保护机制,目的就是当这种无节制的线程资源申请发生时,拒绝新的任务保护线程池。默认拒绝策略会直接报异常,但是JDK中一共提供了4种保护策略,如下:

实际上拒绝策略都是现实自接口RejectedExecutionException,开发者也可以通过实现此接口,定制自己的拒绝策略。

阿里为何进制使用Executors工具类创建线程池,尤其是newFixedThreadPool()、newCachedThreadPool()这两个方法?

比如使用newFixedThreadPoolExecutor方法创建线程的案例:

上述代码创建了一个固定数量为2的线程池,并通过for循环向线程池中提交100万个任务。通过java -Xms4m -Xmx4m FixedthreadPoolOOm 执行上述代码:

可以发现,当任务添加到7万多个时,程序发生了OOM。为什么呢,我们看下newFixedThreadPool()的具体实现,如下:

可以看到传入的是一个无界的阻塞队列,理论上是可以无限添加任务到线程池。当核心线程执行时间很长,则新提交的任务还在不断的插入到阻塞队列中,最终造成OOM。

再看下newCachedThreadPool会有什么问题?

上述同样会报OOM,只是错误的log信息有点区别:无法创建新的线程

看一下newCachedThreadPool的实现:

可以看到,缓存线程池的最大线程数为Integer最大值,当核心线程耗时很久,线程会尝试创建新的线程来执行提交任务,当内存不足时,就会报无法创建线程的错误。

   以上两种情况如果发生在生产环境将会是致命的,从阿里手册中严禁使用Executors的态度上能看出,阿里也是经过血淋淋的教训。

第12讲:DVM以及ART是如何对JVM进行优化的

什么是Dalvik

       Dalvik是Google公司自己设计用于Android平台的Java虚拟机,Android工程师编写的Java或者kotlin代码最终都是在这台虚拟机中被执行。在Android5.0之前叫做DVM,5.0之后改为ART(Android Runtime)。

       DVM大多数实现与传统的JVM相同,但是因为Android最初设计用于手机端,对内存空间要求较高,并且起初Dalvik目标是只运行在ARM架构的CPU上。

Android 65535问题根本原因是在DVM源码中的MemberIdsSection.java类中,有如下一段代码:

@Override
protected void orderItems(){
    int idx =0;
    if(items.size() > DexFormat.MAX_MEMBER_IDX +1){
        throw new DexException(Main.TO_MANY_ID_ERROR_MESSAGE);
    }
    for(Object i : item()){
        ((MemberIdItem)i).setIndex(idx);
        idx++;
    }
}

DexFormat.MAX_MEMBER_IDX的值为65535,items代表文件中的方法个数、属性个数、以及类的个数。也就是说理论上不止方法数,我们在Java中声明变量,或者创建的类的个数如果超过65535,同样也会编译失败。Android提供了MultiDex来解决这个问题。

架构基于寄存器&基于堆栈结构

    JVM的指令集是基于栈结构来执行的;而Android却是基于寄存器的,不过这里不是直接操作硬件的寄存器,而是在内存中模拟一组寄存器。Android的字节码(smali)和Java 字节码完全不同,Android的字节码更多的二地址指令和三地址指令。基于寄存器的指令明显会比基于栈的指令少,虽然增加了指令长度但却缩减了指令的数量,执行也更为快速。用一张表格来对比基于栈和基于寄存器的实现方式如下:

内存管理与回收

DVM与JVM另一个比较显著的不同就是内存结构的区别,主要体现在对“堆”内存的管理。Dalvik虚拟机中的堆被划分为了2部分:Activie Heap和Zygote Heap。

为什么要分Zygote和Active两部分?

     Android系统的第一个Dalvik虚拟机是由Zygote进程创建的,而应用程序进程是由zygote进程fork出来的。Zygote是在系统启动的时产生的,它会完成虚拟机的初始化,库的加载,预置类库的加载和初始化等操作,而在系统需要一个新的虚拟机实例时,Zygote通过复制自身,最快速的提供一个进程;另外,对于一个只读的系统库,所有虚拟机实例都和Zygote共享一块内存区域,大大节省了内存开销。

     当启动一个应用时,Android操作系统需要为应用程序创建新的进程,而这一步操作是通过一种写时拷贝技术直接复制Zygote进程而来。这意味着开始的时候,应用进程和Zygote进程共享了一个用来分配对象的堆。当然,当Zygote进程或者应用程序进程对该堆进行写操作时,内核就会执行真正的拷贝操作,使得Zygote进程和应用程序进程分别拥有自己的一份拷贝,拷贝是一件费时费力的事情,因此,为了尽量避免拷贝,Dalvik虚拟机将自己的堆划分为两部分

     Dalvik堆起初只有一个,但是当Zygote进程fork第一个应用程序之前,会将自己已经使用的那部分堆内存划分为一部分,把还没有使用的堆内存划分为另外一部分。前者就成为Zygote堆,后者就称为Active堆。以后无论是Zygote进程还是应用程序进程,当它们需要分配对象时,都在Active堆上进行。这样就可以使得Zygote堆尽可能少地被执行写操作,因而就可以减少执行写时拷贝的操作时间。

Dalvik虚拟机堆

         在Dalvik虚拟机中,堆实际上就是一块匿名共享内存。Dalvik虚拟机并不直接管理这块匿名内存,而是将他封装成一个mspace,交给C库来管理。因为内存碎片问题其实是一个通用的问题,不止是Dalvik虚拟机在Java堆为对象分配内存时遇到,C库的malloc函数在分配内存时也会遇到。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值