多线程

1.线程和进程

  进程可以看成是一个完整的程序,比如QQ有多个进程,当你双击QQ时在电脑的服务管理器中就可以看见新生成一个QQ进程,当然这个新生成的程序只是多个QQ进程中的其中一个,对于这个新进程来讲,它包含多个线程,比如你可以看到QQ界面,同时你可以发送消息,他们每个功能就是一个线程,而这多个线程组成了进程,假设其中一个线程挂掉,就会损失一个功能,但是当整个进程挂掉,整个程序就挂了。

    当我们在idea中起一个多线程,然后让多线程中while循环,会发现有些多线程执行完毕,整个程序还在执行,由于是死循环,所以整个程序会一直执行下去,也就是整个程序是一个进程,该进程会等到所有的程序执行完毕。个人理解:进入mian方法的线程为主线程,其余的多线程是在主线程上中创建,但是主线程结束并不是进程结束,等所有的线程结束,进程才会结束。

2.使用多线程的目的

       

    如上图所示:当程序执行时,程序具体执行具体指令实在cpu中,cpu会不停的切换线程执行,由于切换速度过快,所以我们人为是无法感知到的,当多核时,多个线程就可以同时执行了,多线程不能提高线程的执行速度,但是可以提高进程的执行速度,比如下载一份文件,可以多个线程同时执行,并将结果汇总。这样速度就提高了,当然,前提时程序要有足够的执行资源。

3.线程的同步和异步

  多线程并发时,多个线程同时请求同一个资源,必然导致此资源的数据不安全,A线程修改了B线程的处理的数据,而B线程又修改了A线程处理的数理。显然这是由于全局资源造成的,有时为了解决此问题,优先考虑使用局部变量,退而求其次使用同步代码块,出于这样的安考虑就必须牺牲系统处理性能,加在多线程并发时资源挣夺最激烈的地方,这就实现了线程的同步机制同步:A线程要请求某个资源,但是此资源正在被B线程使用中,因为同步机制存在,A线程请求不到,怎么办,A线程只能等待下去。异步:A线程要请求某个资源,但是此资源正在被B线程使用中,因为没有同步机制存在,A线程仍然请求的到,A线程无需等待

          

4.创建多线程方式

          1>继承thread类

package paic.thred.create;

public class ExtendThread {
    public static void main(String[] args) throws InterruptedException {
        new ThreadTest().start();/**start方法启动多线程*/
        /**当该线程启动之后,该线程将和主线程交替执行*/
        for (int i=0;i<10;i++){
            Thread.currentThread().setName("main");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getId()+"====="+Thread.currentThread().getName());
        }
    }
}
class ThreadTest extends Thread{
    @lombok.SneakyThrows
    @Override
    public void run() {
        for (int i=0;i<10;i++){
            Thread.currentThread().setName("ThreadTest");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getId()+"====="+Thread.currentThread().getName());
        }
    }
}

 

执行结果:交替执行

 

 

    2>实现runnable方法

package paic.thred.create;

public class ImpermentThread {
    public static void main(String[] args) throws InterruptedException {
        /****通过实现runnable的方法实现多线程**/
        new Thread(new RunnableTest()).start();/**start方法启动多线程,多线程启动之后交替执行*/
        for (int i=0;i<10;i++){
            Thread.currentThread().setName("Main");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getId()+"====="+Thread.currentThread().getName());
        }
    }
}
class RunnableTest implements Runnable{
    @lombok.SneakyThrows
    public void run() {
        for (int i=0;i<10;i++){
            Thread.currentThread().setName("RunnableTest");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getId()+"====="+Thread.currentThread().getName());
        }
    }
}

 

执行结果:交替执行

 

   3>内部类

     个人觉得其实就是实现runnable方法,和上述第二种一样

package paic.thred.create;

import lombok.SneakyThrows;

public class ClassThread {
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @SneakyThrows
            public void run() {
                for (int i=0;i<10;i++){
                    Thread.sleep(1000);
                    Thread.currentThread().setName("ClassThread");
                    System.out.println(Thread.currentThread().getName());
                }

            }
        }).start();
        for (int i=0;i<10;i++){
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName());
        }

    }
}

执行结果:交替执行

 

 

 

 

5.多线程的状态

         总共五种状态:新建,就绪,运行,阻塞,死亡

         

 

 

 

 

6.多线程的种类

          分为守护线程和非守护线程(用户线程),用户线程就是用户自己创建的,当主线程死亡时,对用户线程没有影响,守护线程和主线程一起销毁。   

               

 

 

 

          利用setDaemon(true)方法 可以将线程设置为守护线程。程序如下:

package paic.thred.create;
import lombok.SneakyThrows;
public class ClassThread {
    public static void main(String[] args) throws InterruptedException {
     Thread t1= new Thread(new Runnable() {
            @SneakyThrows
            public void run() {
                for (int i=0;i<10;i++){
                    Thread.sleep(1000);
                    Thread.currentThread().setName("ClassThread");
                    System.out.println(Thread.currentThread().getName());
                }

            }
        });
     t1.setDaemon(true);/**要写在start之前,因为当程序执行到start时,该线程状态为就绪状体,后面就不能再更改线程属性了*/
     t1.start();
     /**设置该线程时守护线程,当主线程结束时该线程也销毁*/
        for (int i=0;i<3;i++){
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName());
        }
    }
}

 

7.多线程中的join

  join是多线程中的一个方法,假设有两个线程,t1和t2,要求当t1执行完成之后才能执行t2.也就是t1的优先级高于t2时,再t2中调用t1.join()方法即可,也就是cpu会先抢占t1线程,等待t1线程执行完成之后才会抢占t2线程执行。

package paic.thred.create;
import lombok.SneakyThrows;
public class ClassThread {
    public static void main(String[] args) throws InterruptedException {
        final Thread t1 = new Thread(new Runnable() {
            @SneakyThrows
            public void run() {
                for (int i = 0; i < 3; i++) {
                    Thread.sleep(10);
                    Thread.currentThread().setName("t1");
                    System.out.println(Thread.currentThread().getName());
                }
            }
        });
        final Thread t2 = new Thread(new Runnable() {
            @SneakyThrows
            public void run() {
                t1.join();/***当t1执行完毕之后才会执行t2,也即是cpu会先抢占t1线程,等t1线程执行完才会抢占t2线程*/
                for (int i = 0; i < 3; i++) {
                    Thread.sleep(10);
                    Thread.currentThread().setName("t2");
                    System.out.println(Thread.currentThread().getName());
                }

            }
        });

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

执行结果:t1线程先执行完成,之后执行t2线程

 

8.多线程的安全问题

          多线程发生安全问题的原因是有多个线程同时操作(增删改)了一个成员变量,导致该变量值并不是每个线程所需操作后的值。最根本的原因是再Java内存模型中线程之间是互相不可见的。

          举例子:多个窗口购买100张火车票,代码如下:

 

package paic.thred.create;

import lombok.SneakyThrows;

public class ThreadDemo01 implements Runnable{
    private int count=100;/**表示有100张火车票*/
    @SneakyThrows
    public void run() {
        Thread.sleep(10);
        while(count>0){
            Thread.sleep(100);
            del();/**表示该多线程购票*/
        }
    }
    private synchronized void del() {
        if(count>0) {/**判断是为了确认当最后一次访问的时候假设已经为0了,就不再减少了*/
                    count--;/**表示出售一张火车票*/
                    System.out.println("当前的火车票为:" + count + "====" + Thread.currentThread().getName());
                }
    }
}

 

package paic.thred.create;

public class OrderThredMain {
    public static void main(String[] args) {
        ThreadDemo01 threadDemo01 = new ThreadDemo01();/**可以看成是一份资源*/
        Thread t1 = new Thread(threadDemo01,"窗口1");
        Thread t2 = new Thread(threadDemo01,"窗口2");
        t1.start();/**启动第一个线程*/
        t2.start();/**启动第二个线程*/
    }
}

执行结果:

 

         synchronized 关键字:

             内置锁(synchronized),保证原子性,当线程进入方法的时候自动获取锁,一旦锁被其他的线程获取,其余的线程必须等待,加了synchronized会降低程序的执行效率,原因是竞争锁资源是需要消耗资源的。内置锁也是互斥锁(一个使用之后别的只能处于等待状体)。synchronized的使用方法:加在方法上和代码块上

 

 

             上述的程序还可以用:

public class ThreadDemo01 implements Runnable{
    private int count=100;/**表示有100张火车票*/
    private Object obj=new Object();
    @SneakyThrows
    public void run() {
        Thread.sleep(10);
        while(count>0){
            Thread.sleep(100);
            del();/**表示该多线程购票*/
        }
    }
    private  void del() {
        synchronized (obj){/**syschronized用于同步代码块,()中的参数为任意成员对象,obj和this都行,这里的this,
         就是主线程中new的对象,因为主线程中只new了一个对象,所以使用的是同一把锁,个人理解同一个对象就是同一把锁*/
            if(count>0)
            {/**判断是为了确认当最后一次访问的时候假设已经为0了,就不再减少了*/
                count--;/**表示出售一张火车票*/
                System.out.println("当前的火车票为:" + count + "====" + Thread.currentThread().getName());
            }
        }
    }
}

         如果在主线程中换成如下方式,如何解决问题:

public static void main(String[] args) {
        ThreadDemo01 threadDemo01 = new ThreadDemo01();/**第一份资源*/
        ThreadDemo01 threadDemo02 = new ThreadDemo01();/**第二份资源*/
        Thread t1 = new Thread(threadDemo01,"窗口1");
        Thread t2 = new Thread(threadDemo02,"窗口2");
        /**这里相当于两个线程在访问不同的资源,如果要保持访问一个变量,可以将变量设置为静态变量(存放于方法区),然后
         * 我们需要一把锁锁住该变量*/
        t1.start();
        t2.start();
    }

          很显然,当变量为private static int count=100;时,这里的两个线程访问的时同一个count变量,但是obj和this已经不能做到同步了,因为两个线程都有自己的obj和this,我们可以将obj设置为static类型,同样可以做到同步。也就是当obj为static类型时,两个线程使用的是同一把锁。

    private static int count=100;/**表示有100张火车票*/
    private static Object obj=new Object();

 

        同样,既然静态变量可以实现,那将静态变量换成字节码文件是否可以?因为两个对象的字节码文件是同一个,测试结果依旧是同步的。

synchronized (ThreadDemo01.class){/**多个对象,但字节码文件相同,所以是同一把锁*/
            if(count>0)
            {/**判断是为了确认当最后一次访问的时候假设已经为0了,就不再减少了*/
                count--;/**表示出售一张火车票*/
                System.out.println("当前的火车票为:" + count + "====" + Thread.currentThread().getName());
            }
        }

        继续看以下代码:

class ThreadDemo02 implements Runnable {
    // 同时多个窗口共享100
    private   static int count = 100;// 存放方法区中
    private static Object oj = new Object();
    public boolean flag = true;

    @SneakyThrows
    public void run() {
        if (flag) {
            while (count > 0) {
                synchronized (oj) {/**当这里为obj时,如果要保持同步,else中的必须为obj,同理ThreadDemo02.class,else中也必须为ThreadDemo02.class*/
                    Thread.sleep(10);
                    if (count > 0) {
                        System.out.println(Thread.currentThread().getName() + ",出售" + (100 - count + 1) + "张票");
                        count--;
                    }
                }
            }
        } else {
            while (count > 0) {
                sale();
            }
        }
    }
    public   static  void sale() throws InterruptedException {
        synchronized (oj) {/**如果要保持同步,则必须和上面的同步代码块的参数对象一样*/
                Thread.sleep(10);
                if (count > 0) {
                System.out.println(Thread.currentThread().getName() + ",出售" + (100 - count + 1) + "张票");
                count--;
            }
        }
    }
}
public class Test0001 {
    public static void main(String[] args) throws InterruptedException {
        ThreadDemo02 threadDemo01 = new ThreadDemo02();
        ThreadDemo02 threadDemo02 = new ThreadDemo02();/**首先创建两个资源*/
        Thread t1 = new Thread(threadDemo01, "窗口1");
        Thread t2 = new Thread(threadDemo02, "窗口2");/**创建两个线程*/
        t1.start();
        Thread.sleep(40);
        threadDemo02.flag = false;/**通过更改变量实现让线程访问不同的方法,也就是让窗口2访问else中的while,而窗口1访问if中的while*/
        t2.start();
    }
}

 运行结果:代码是同步的。但是当第一个代码块参数为obj,第二个为class字节码时,或者第二个为方法上加synchronized时,都不是同步的。

             个人理解: 使用synchronized同步多线程资源时,必须保证你要修改(增删改)的变量所在的代码加的锁是同一个,即如果锁是obj,则所有资源外的锁都是obj,如果是class,则所有都是class,另外:当资源是非静态的时,一般锁使用this,当资源是静态时,一般锁使用class字节码文件。比如在方法上加synchronized时,如果该方法是静态方法,则默认class文件为锁,当方法是非静态方法时,则默认this为锁。

 

 
9.多线程中的死锁问题

        同步中嵌套同步,导致锁无法释放,比如当一个程序中,用到了两种锁,A1和A2,当线程b1进入A1,等待A2,线程b2进入A2,等待A1,这时线程会处于相互等待的状态,多线程这种状态就是死锁。个人理解:多线程中只用到一种锁时是不会有死锁问题。

           代码如下:

class ThreadDemo02 implements Runnable {
    // 同时多个窗口共享100
    private  int count = 100;// 存放方法区中
    private  Object oj = new Object();
    public boolean flag = true;

    @SneakyThrows
    public void run() {
        if (flag) {
            while (count > 0) {
                synchronized (oj) {/**先进on锁,后进this锁*/
                    Thread.sleep(10);/**因为主线程有sleep,所以首先是线程1进入,然后到此休眠,线程2进入(事实上和线程抢占线程有关,这里只是模拟而已),等这边就绪,等待this锁,而线程2等待oj锁,所以死锁*/
                    synchronized (this){
                        if (count > 0) {
                            System.out.println(Thread.currentThread().getName() + ",出售" + (100 - count + 1) + "张票");
                            count--;
                        }
                    }
                }
            }
        } else {
            while (count > 0) {
                sale();
            }
        }
    }
    public synchronized void sale() throws InterruptedException {/**方法上加了this锁*/
        Thread.sleep(10);
        synchronized (oj) {/**加了oj锁,这样先进this锁,后进oj锁*/
                Thread.sleep(10);
                if (count > 0) {
                System.out.println(Thread.currentThread().getName() + ",出售" + (100 - count + 1) + "张票");
                count--;
            }
        }
    }
}
public class Test0001 {
    public static void main(String[] args) throws InterruptedException {
        ThreadDemo02 threadDemo01 = new ThreadDemo02();
        Thread t1 = new Thread(threadDemo01, "窗口1");
        Thread t2 = new Thread(threadDemo01, "窗口2");/**创建两个线程*/
        t1.start();
        Thread.sleep(40);
        threadDemo01.flag = false;/**通过更改变量实现让线程访问不同的方法*/
        t2.start();
    }
}

运行结果:

    

 

 

 10.关键字ThreadLocal

           ThreadLocal提高一个线程的局部变量,访问某个线程拥有自己局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

 

public class ThreadLocalTest extends Thread{
    private SumTest sumTest;
    public ThreadLocalTest(SumTest sumTest){
        this.sumTest=sumTest;/**综合全局程序,多个线程访问同一个对象,对象中有变量,对对象中的变量进行改变
         相当于改变一个变量,但是这个变量是在threadLocal中的*/
    }
    @Override
    public void run() {
            for (int i=0;i<=3;i++){
                i=sumTest.getSum();
                System.out.println(Thread.currentThread().getName()+"========="+i);
            }
    }

    public static void main(String[] args) {
        SumTest test=new SumTest();
        ThreadLocalTest threadLocalTest = new ThreadLocalTest(test);
        threadLocalTest.setName("线程1");
        ThreadLocalTest threadLocalTest1 = new ThreadLocalTest(test);
        threadLocalTest1.setName("线程2");
        ThreadLocalTest threadLocalTest2 = new ThreadLocalTest(test);
        threadLocalTest2.setName("线程3");
        threadLocalTest.start();
        threadLocalTest1.start();
        threadLocalTest2.start();
    }
}
class SumTest{
    private static Integer count;
    private static ThreadLocal<Integer> threadLocal=new ThreadLocal<Integer>(){
      protected Integer initialValue(){/**初始化thread中Integer类型的值*/
          return 0;/**初始值为0*/
      }
    };
    public Integer getSum(){
        count= threadLocal.get()+1;/**获取当前threadlocal中Interget类型的值,并加1*/
        threadLocal.set(count);/**将count的值加入到threadLocal中,相当于将公共变量加入到


内存泄漏:

 

 

提到Threadlocal 必然不得不说他的内存泄露问题,首先我们注意下,threadlocalmap 是一个弱引用,也就是说只要gc ,我们上面这条 ThreadLocal->key 的虚线就回被打断,但是如果我们的当前线程没有结束,那么指向value的强引用自然不会断开。

于是当gc发生的时候,我们的map中就出现了一个,key为null的 entry,但是这个entry 由于value的强引用并不会被回收,那么map中就出现了一个永远访问不到的entry!内存泄漏由此产生

当然,threadLocal 对此还是有对策的,当我们调用 get set 和 remove 方法的时候,threadlocal都会去检查是否有这样的entry并把他们清楚掉。

但是注意,除了 remove方法,其他两个方法都是不可靠的,他们并非及时同时不一定会去执行清除方法,所以为了防止内存泄露,我们必须在使用完Threadlocal后及时的去调用remove方法!

为什么使用弱引用

说道这里可能很多人会问了,为什么threadlocal要采用弱引用,采用强引用不是就没有这个访问不到entry的毛病了吗。

那我们这里就假设一下,如果这里的key是强引用的话,如果我们给这个Threadlocal 赋值为null

那么情况就如下图所示

 

312663e53e961c20a148227502c339f1.png

请大家注意这里 指向我们value的线已经断了,照理来说这个entry应该被回收,但是由于我们的key是强引用,所以这个entry就变得和 Threadlocal同生共死了,从而造成内存泄露!

但是如果是弱引用,就不存在上述的情况了。

 

 

11.关键字volatile

         可见性也就是说一旦某个线程修改了该被volatile修饰的变量,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,可以立即获取修改之后的值。在Java中为了加快程序的运行效率,对一些变量的操作通常是在该线程的寄存器或是CPU缓存上进行的,之后才会同步到主存中,而加了volatile修饰符的变量则是直接读写主存。

       Java内存模型详细请见:https://www.cnblogs.com/YJK923/p/10478716.html

         一般情况下在多线程中,我们会在共享变量前面加volatile,原因是为了提高效率,让主内存中的值尽可能快的和副本中的数据同步,但是该参数不能实现多线程安全,但是可以保证禁止指针重排序

         synchronized修饰,表示单线程了,指针重排序对单线程不会有影响。

     

 

 12.多线程进行读写操作

package paic.thred.create;
import lombok.SneakyThrows;
public class ReadWrite {
    public static void main(String[] args) {
        Student student=new Student();
        new Thread(new StudentReadRunnbale(student)).start();
        new Thread(new StudentWriteRunnabel(student)).start();
    }
}
class StudentReadRunnbale implements Runnable{
    private  Student student;
    StudentReadRunnbale(Student student){
        this.student=student;
    }
    @SneakyThrows
    public void run() {
        int i=0;
        while(true){
            synchronized (student){/**同一把锁,就是当程序运行的时候,写的时候读不了,读的时候写不了*/
                if(i%2==0){
                    student.name="小军";
                    student.sex="";
                }else {
                    student.name="小红";
                    student.sex="";
                }
                i++;
            }
        }
    }
}
class StudentWriteRunnabel implements Runnable{
    private  Student student;
    StudentWriteRunnabel(Student student){/**可以保证性别和名称对应*/
        this.student=student;
    }
    @SneakyThrows
    public void run() {
        while(true){
        synchronized (student){
            System.out.println(student.sex+":"+student.name);
            }
        }
    }
}
class Student{
    public String name;
    public String sex;
}

执行结果:但是男女不一一对应,原因是线程会抢占cpu资源,所以如果读的在一段时间占用cpu,则一直读出来的是一个值。

 

 

13.wait和notify

               wait:在synchronized方法中使用,表示让当前线程等待,并且释放synchronized所在的锁,也就是当在syschronized方法中执行到wait时,表示该方法上加了锁,别的方法访问共享变量了,但是当wait一执行,那当前线程就不执行了,一直等待,其余的线程可以执行共享变量了。

                   notify:表示唤醒现在进入睡眠的线程,让该线程继续执行。

案例:

package paic.thred.create;
import lombok.SneakyThrows;
public class ReadWrite {
    public static void main(String[] args) {
        Student student=new Student();
        new Thread(new StudentReadRunnbale(student)).start();
        new Thread(new StudentWriteRunnabel(student)).start();
    }
}
class StudentReadRunnbale implements Runnable{
    private  Student student;
    StudentReadRunnbale(Student student){
        this.student=student;
    }
    @SneakyThrows
    public void run() {
        int i=0;
        while(true){
            synchronized (student){/**同一把锁,就是当程序运行的时候,写的时候读不了,读的时候写不了*/
                if(student.flag){
                    student.wait();/**如果是true,则线程等待,并释放掉该线程锁占用的对象锁student*/
                }
                if(i%2==0){
                    student.name="小军";
                    student.sex="";
                }else {
                    student.name="小红";
                    student.sex="";
                }
                i++;
                student.flag=true;/**能执行到此,说明之前的flag为flase,改为true,就是当线程再次进入,则会等待*/
                student.notify();/**唤醒读线程中的sleep*/
            }
        }
    }
}
class StudentWriteRunnabel implements Runnable{
    private  Student student;
    StudentWriteRunnabel(Student student){/**可以保证性别和名称对应*/
        this.student=student;
    }
    @SneakyThrows
    public void run() {
        while(true){
        synchronized (student){
            if(!student.flag){/**表示当为false时,让该线程等待,释放当前线程所占用的student对象锁,让别的线程先执行共享变量*/
                student.wait();/**个人理解:wait是让当前线程等待,等待别的线程调用notify唤醒,并且释放掉student对象锁,让别的线程课可以执行*/
            }
            System.out.println(student.sex+":"+student.name);
            student.flag=false;/**如果执行到这儿了,说明当前的flag=true,我们让flag=false,并且唤醒当前睡眠的写线程*/
            student.notify();/**写线程中的sleep*/
        }
        }
    }
}
class Student{
    public String name;
    public String sex;
    public boolean flag=false;
}

执行结果:

       

 

         wait和notify是object方法,是作用到对象上的,如果直接用new Student().wait(),会报空指针异常,但是在sychronized(student){}中,因为已经有了student对象锁,所以可以用sudent.wait()直接执行。

14.cas

CAS,compare and swap的缩写,中文翻译成比较并交换。

我们都知道,在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商老早就在芯片中加入了大量直至并发操作的原语,从而在硬件层面提升效率。在intel的CPU中,使用cmpxchg指令。

在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。而随着java不断的发展,Java本地方法(JNI)的出现,使得java程序越过JVM直接调用本地方法提供了一种便捷的方式,因而java在并发的手段上也多了起来。而在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基石。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

二、CAS的目的

 利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

三、CAS存在的问题

 CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

四.底层使用unsafe类实现

public class AtomicTest {
    private AtomicInteger atomicInteger=new AtomicInteger(1);
    private AtomicStampedReference atomicStampedReference=new AtomicStampedReference(1,1);
    @Test
    public void atomicTest() throws Exception{
      Thread thr=  new Thread(()->{
          atomicInteger.compareAndSet(1,2);
          atomicInteger.compareAndSet(2,1);
          atomicInteger.weakCompareAndSet(1,2);
          atomicInteger.weakCompareAndSet(2,1);
          try {
              Thread.sleep(100);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
          atomicStampedReference.compareAndSet(1,2,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
          atomicStampedReference.compareAndSet(2,1,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
        });
      thr.start();
       Thread t2= new Thread(()->{
            System.out.println(atomicInteger.compareAndSet(1,2));
            int aa=atomicStampedReference.getStamp();
           try {
               thr.join();
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
           System.out.println(atomicStampedReference.compareAndSet(1,2,aa,atomicStampedReference.getStamp()+1));
        });
       t2.start();
       thr.join();
       t2.join();
    }

使用AtomicInteger 有可能会有ABA问题。但是使用AtomicStampedReference 可以避免该问题,主要原因是因为该类在调用cas方法的时候加入了版本的概念,相当于不但是要检测值的改变,还要检测版本是否发生变化。

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

加入版本:

 public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

 

 

15.LongAdder

JDK8中新增了一个原子性递增或者递减类LongAdder用来克服高并发下使用AtomicLong的缺点。LongAdder的思路是把一个变量分解为多个变量,让同样多的线程去竞争多个资源。如图所示:

 

 

使用LongAdder时,内部维护了多个Cell变量,每个Cell里面有一个初始值为0的long型变量,这样同时争取一个变量的线程就变少了,而是分散成对多个变量的竞争,减少了失败次数。如果竞争某个Cell变量失败,它不会一直在这个Cell变量上自旋CAS重试,而是尝试在其他的Cell变量上进行CAS尝试,这个改变增加了当前线程重试CAS成功的可能性。最后,在获取LongAdder当前值时,是把所有Cell变量的value值累加后再加上base返回的。

LongAdder里面有一个Cell数组,是惰性加载的,即需要时创建。当并发线程较少时,所有累加操作都是针对base变量进行。Cell类型是AtomicLong的一个改进,用来减少缓存的争用,也就是解决伪共享问题。因为Cell数组元素的内存地址是连续的,所以数组内的多个元素能经常共享缓存行,因此这里使用@sun.misc.Contended注解对Cell类进行字节填充,防止数组中多个元素共享一个缓存行,提升性能。

 16.Lock锁

         在使用synchronized的地方可以替换成lock锁,比如:

public class LockTest implements Runnable{
    private volatile int age=0;
    private static final Lock lock=new ReentrantLock();
    @Override
    public  void run() {
        synchronized (this){
            age=age+1;
            System.out.println(age);
        }
    }
    public static void main(String[] args) {
       LockTest runnable= new LockTest();
       new Thread(runnable).start();
       new Thread(runnable).start();

    }
}

可以使用如下方式替换:

public class LockTest implements Runnable{
    private volatile int age=0;
    private static final Lock lock=new ReentrantLock();
    @Override
    public  void run() {
          lock.lock();
          try {
          age=age+1;
          System.out.println(age);
        }catch (Exception e){
             
          }finall{
 lock.unlock();

} }
public static void main(String[] args) { LockTest runnable= new LockTest(); new Thread(runnable).start(); new Thread(runnable).start(); } }

注意,在使用lock锁时,必须要使用try catch捕捉异常,然后释放锁,synchronized会自动释放

 lock锁等待及唤醒:

class MyCondition implements Runnable{
    private Lock lock = new ReentrantLock();
    public Condition condition = lock.newCondition();
    public void run() {
        try {
            //上锁
            lock.lock();
            System.out.println(" 开始等待时间:"+System.currentTimeMillis());
            System.out.println("我陷入了等待...");
            //线程等待
            condition.await();
            //释放锁
            lock.unlock();
            System.out.println("锁释放了!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    //通知方法
    public void signal(){
        try {
            lock.lock();
            System.out.println("结束等待时间:"+System.currentTimeMillis());
             //通知等待线程
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
}
public class MyLock{
    public static void main(String[] args) throws InterruptedException {
        MyCondition myCondition = new MyCondition();
        Thread thread1 = new Thread(myCondition,"线程1");
        thread1.start();
        Thread.sleep(3000);
        myCondition.signal();
    }
}

lock锁分为公平锁和非公平锁,公平锁是先进先出,按照顺序进行,非公平锁是谁抢到就算谁的。

17.一个简单的案例

实现一个容器,提供两个方法,add,get
写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束

 写法一:个人感觉在线程一种做了等待,相当于线程一种也加了监控,并不是很适合

 private /*volatile*/ List<Object>list=new ArrayList<>();
   public  void add(){
       list.add(new Object());
   }
   public int get(){
     return list.size();
   }
    public static void main(String[] args) {
        ThreadTask threadTask=new ThreadTask();
        Thread t1=   new Thread(()->{
            synchronized (threadTask){
            for(int i=0;i<10;i++) {
                threadTask.add();
                System.out.println(Thread.currentThread().getName() + ":添加了一个对象");
                if(i==4){
                    try {
                        threadTask.notify();
                        threadTask.wait();
                    }catch (Exception e){

                    }
                  }
               }
            } },"thread task 1");
        Thread t2=   new Thread(()->{
              synchronized (threadTask){
                  try {
                      if(threadTask.get()!=5){
                          threadTask.wait();
                      }
                      System.out.println("线程二结束");
                      threadTask.notify();
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
              }
            },"thread task 2");
        t2.start();
        t1.start();

    }

使用lock锁,问题:当运行时,第一次可以唤醒,第二次唤醒不了了

private /*volatile*/ List<Object>list=new ArrayList<>();
   public  void add(){
       list.add(new Object());
   }
   public int get(){
     return list.size();
   }
    public static void main(String[] args) throws InterruptedException {
        ThreadTask threadTask=new ThreadTask();
        Lock lock=new ReentrantLock();
        Condition condition=lock.newCondition();
        Thread t1=   new Thread(()->{
            lock.lock();
                 try {
                 for(int i=0;i<10;i++) {
                     threadTask.add();
                         System.out.println(Thread.currentThread().getName() + ":添加了一个对象");
                         if(i==4){
                             condition.signalAll();
                             System.out.println("第一个signal=====");
                             condition.wait();
                             System.out.println("第一个wail=====");
                         }
                       }
                     }catch (Exception e){}finally {
                     lock.unlock();
                 }
            },"thread task 1");
        Thread t2=   new Thread(()->{
                  lock.lock();
                  try {
                      if(threadTask.get()!=5){
                        condition.await();
                      }
                      System.out.println(Thread.currentThread().getName()+":线程结束");
                      condition.signalAll();
                      System.out.println("condition 唤醒");
                      Thread.sleep(1000);
                    } catch (InterruptedException e) {
                      e.printStackTrace();
                  }finally {
                      lock.unlock();
                  }
            },"thread task 2");
        t2.start();
        t1.start();
   }

运行结果:

thread task 1:添加了一个对象
thread task 1:添加了一个对象
thread task 1:添加了一个对象
thread task 1:添加了一个对象
thread task 1:添加了一个对象
第一个signal=====
thread task 2:线程结束
condition 唤醒

使用countLunch处理:

public class T05_CountDownLatch {
    
    volatile List lists = new ArrayList();

    public void add(Object o) {
        lists.add(o);
    }

    public int size() {
        return lists.size();
    }

    public static void main(String[] args) {
        T05_CountDownLatch c = new T05_CountDownLatch();

        CountDownLatch latch = new CountDownLatch(1);

        new Thread(() -> {
            System.out.println("t2����");
            if (c.size() != 5) {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t2 ����");

        }, "t2").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        new Thread(() -> {
            System.out.println("t1����");
            for (int i = 0; i < 10; i++) {
                c.add(new Object());
                System.out.println("add " + i);

                if (c.size() == 5) {
                    // �����ţ���t2����ִ��
                    latch.countDown();
                }
            }

        }, "t1").start();

    }
}

使用LockSport处理:Locksport是调用原子操作,不需要加锁就可以打断线程运行状态。

public class ThreadTask{
    /**
     * 实现一个容器,提供两个方法,add,get
     *  * 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束
     * */
   private /*volatile*/ List<Object>list=new ArrayList<>();
   public  void add(){
       list.add(new Object());
   }
   public int get(){
     return list.size();
   }
    public static void main(String[] args) throws InterruptedException {
        ThreadTask threadTask=new ThreadTask();
        Thread t2=   new Thread(()->{
            try {
                if(threadTask.get()!=5){
                    LockSupport.park();
                }
                System.out.println(Thread.currentThread().getName()+":线程结束");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
            }
        },"thread task 2");
        Thread t1=   new Thread(()->{
                 try {
                 for(int i=0;i<10;i++) {
                     threadTask.add();
                         System.out.println(Thread.currentThread().getName() + ":添加了一个对象");
                         if(i==4){
                             LockSupport.unpark(t2);
                         }
                         Thread.sleep(1000);
                       }
                     }catch (Exception e){}finally {
                 }
            },"thread task 1");

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

18.各种锁简介

生活中用到的锁,用途都比较简单粗暴,上锁基本是为了防止外人进来、电动车被偷等等。但生活中也不是没有 BUG 的,比如加锁的电动车在「广西 - 窃·格瓦拉」面前,锁就是形同虚设,只要他愿意,他就可以轻轻松松地把你电动车给「顺走」,不然打工怎么会是他这辈子不可能的事情呢?牛逼之人,必有牛逼之处。那在编程世界里,「锁」更是五花八门,多种多样,每种锁的加锁开销以及应用场景也可能会不同。如何用好锁,也是程序员的基本素养之一了。高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。所以,知道各种锁的开销,以及应用场景是很有必要的。接下来,就谈一谈常见的这几种锁:

 

 

 多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。对症下药,才能减少锁对高并发性能的影响。那接下来,针对不同的应用场景,谈一谈「互斥锁、自旋锁、读写锁、乐观锁、悲观锁」的选择和使用。

前言互斥锁与自旋锁:谁更轻松自如?

最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。

加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;

  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

 

 

 所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。那这个开销成本是什么呢?会有两次线程上下文切换的成本:

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行

线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,
不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,
因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

 读写锁:读和写还有优先级区分?

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。

所以,读写锁适用于能明确区分读操作和写操作的场景。读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取读锁。如下图:

 


 而写优先锁是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取读锁。如下图:

 

 

 读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

乐观锁与悲观锁:做事的心态有何不同?

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。这里举一个场景例子:在线文档。我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

总结

开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

转载于:https://mp.weixin.qq.com/s?__biz=MjM5MjAwODM4MA==&mid=2650782415&idx=3&sn=0086163cace46534dbc3385c8038c5ac&chksm=bea7cb1c89d0420a952f314740487b85221500ad92897de6dffbbf2e7567cfe3c0c7dc72b14f&mpshare=1&scene=1&srcid=0610hSCayqQAe9r2s6oAfRMg&sharer_sharetime=1623301318626&sharer_shareid=88d38d65e5ef5c395278e793e20478d1&key=01cad3b0545c2290c0adca95b470de6b9dc7b704410ccfe0947543fbde978c2e97d653929fb04d8473388b0afb8f5bb631fe3bfba0effcb1b7314088cd1b49f3a01218d891d0726d1d07c08c998adbdb99821e0a7fe9892f1fe8a1fd952ac7648d0cfc81523efc0b8c4ead23084195cd43027a6abfc19bf28c7241ef3822a494&ascene=1&uin=MTY0Mjc2NTkwOA%3D%3D&devicetype=Windows+10&version=62060833&lang=zh_CN&exportkey=A9ma4ITt6rdNSYOiLwCYcN4%3D&pass_ticket=g8p%2FbelEUuRjcZdAM9%2Be3gujcn8ndeBwqZpneYJgxro7fIp%2FJ4I4q4eB6%2BpiyhMN&wx_header=0

 

19.sychronized底层实现

 锁升级

Java中的每一个对象都可以作为锁,而在Synchronized实现同步的几种方式中分别为:

普通同步方法:锁是当前实例对象
静态同步方法:锁是当前类的Class对象
同步方法块:锁是Synchronized括号里配置的对象
任何一个对象都一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorExit指令则插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit。

Java对象头
synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,可能值如下所示:

 


 

无锁状态 : 对象的HashCode + 对象分代年龄 + 状态位001

Monitor Record

Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

锁的类型

Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

 

无锁 --> 偏向锁 --> 轻量级 --> 重量级

偏向锁

引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。

加锁:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程(此时会引发竞争,偏向锁会升级为轻量级锁)。

膨胀过程:当前线程执行CAS获取偏向锁失败(这一步是偏向锁的关键),表示在该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁所有权。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,并从偏向锁所有者的私有Monitor Record列表中获取一个空闲的记录,并将Object设置LightWeight Lock状态并且Mark Word中的LockRecord指向刚才持有偏向锁线程的Monitor record,最后被阻塞在安全点的线程被释放,进入到轻量级锁的执行路径中,同时被撤销偏向锁的线程继续往下执行同步代码。

 

 轻量级锁

引入背景:这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒

加锁: 

(1)当对象处于无锁状态时(RecordWord值为HashCode,状态位为001),线程首先从自己的可用moniter record列表中取得一个空闲的moniter record,初始Nest和Owner值分别被预先设置为1和该线程自己的标识,一旦monitor record准备好然后我们通过CAS原子指令安装该monitor record的起始地址到对象头的LockWord字段,如果存在其他线程竞争锁的情况而调用CAS失败,则只需要简单的回到monitorenter重新开始获取锁的过程即可。

(2)对象已经被膨胀同时Owner中保存的线程标识为获取锁的线程自己,这就是重入(reentrant)锁的情况,只需要简单的将Nest加1即可。不需要任何原子操作,效率非常高。

(3)对象已膨胀但Owner的值为NULL,当一个锁上存在阻塞或等待的线程同时锁的前一个拥有者刚释放锁时会出现这种状态,此时多个线程通过CAS原子指令在多线程竞争状态下试图将Owner设置为自己的标识来获得锁,竞争失败的线程在则会进入到第四种情况的执行路径。

(4)对象处于膨胀状态同时Owner不为NULL(被锁住),在调用操作系统的重量级的互斥锁之前先自旋一定的次数,当达到一定的次数时如果仍然没有成功获得锁,则开始准备进入阻塞状态,首先将rfThis的值原子性的加1,由于在加1的过程中可能会被其他线程破坏Object和monitor record之间的关联,所以在原子性加1后需要再进行一次比较以确保LockWord的值没有被改变,当发现被改变后则要重新monitorenter过程。同时再一次观察Owner是否为NULL,如果是则调用CAS参与竞争锁,锁竞争失败则进入到阻塞状态。

 


 锁比较:

 

 

20.lock锁底层实现(AQS)

转载于:https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

前言

Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AbstractQueuedSynchronizer(简称为AQS)实现的。AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。本文会从应用层逐渐深入到原理层,并通过ReentrantLock的基本特性和ReentrantLock与AQS的关联,来深入解读AQS相关独占锁的知识点,同时采取问答的模式来帮助大家理解AQS。由于篇幅原因,本篇文章主要阐述AQS中独占锁的逻辑和Sync Queue,不讲述包含共享锁和Condition Queue的部分(本篇文章核心为AQS原理剖析,只是简单介绍了ReentrantLock,感兴趣同学可以阅读一下ReentrantLock的源码)。

下面列出本篇文章的大纲和思路,以便于大家更好地理解:

1 ReentrantLock

1.1 ReentrantLock特性概览

ReentrantLock意思为可重入锁,指的是一个线程能够对一个临界资源重复加锁。为了帮助大家更好地理解ReentrantLock的特性,我们先将ReentrantLock跟常用的Synchronized进行比较,其特性如下(蓝色部分为本篇文章主要剖析的点):

下面通过伪代码,进行更加直观的比较:

// **************************Synchronized的使用方式************************** // 1.用于代码块 synchronized (this) {} // 2.用于对象 synchronized (object) {} // 3.用于方法 public synchronized voidtest () {} // 4.可重入 for (int i = 0; i < 100; i++) { synchronized (this) {} } // **************************ReentrantLock的使用方式************************** publicvoidtest () throw Exception { // 1.初始化选择公平锁、非公平锁 ReentrantLock lock = new ReentrantLock(true); // 2.可用于代码块 lock.lock(); try { try { // 3.支持多种加锁方式,比较灵活; 具有可重入特性 if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ } } finally { // 4.手动释放锁 lock.unlock() } } finally { lock.unlock(); } }

1.2 ReentrantLock与AQS的关联

通过上文我们已经了解,ReentrantLock支持公平锁和非公平锁(关于公平锁和非公平锁的原理分析,可参考《不可不说的Java“锁”事》),并且ReentrantLock的底层就是由AQS来实现的。那么ReentrantLock是如何通过公平锁和非公平锁与AQS关联起来呢? 我们着重从这两者的加锁过程来理解一下它们与AQS之间的关系(加锁过程中与AQS的关联比较明显,解锁流程后续会介绍)。

非公平锁源码中的加锁流程如下:

// java.util.concurrent.locks.ReentrantLock#NonfairSync // 非公平锁 static final classNonfairSyncextendsSync{ ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... }

这块代码的含义为:

  • 若通过CAS设置变量State(同步状态)成功,也就是获取锁成功,则将当前线程设置为独占线程。

  • 若通过CAS设置变量State(同步状态)失败,也就是获取锁失败,则进入Acquire方法进行后续处理。

第一步很好理解,但第二步获取锁失败后,后续的处理策略是怎么样的呢?这块可能会有以下思考:

  • 某个线程获取锁失败的后续流程是什么呢?有以下两种可能:

(1) 将当前线程获锁结果设置为失败,获取锁流程结束。这种设计会极大降低系统的并发度,并不满足我们实际的需求。所以就需要下面这种流程,也就是AQS框架的处理流程。

(2) 存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。

  • 对于问题1的第二种情况,既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

  • 处于排队等候机制中的线程,什么时候可以有机会获取锁呢?

  • 如果处于排队等候机制中的线程一直无法获取锁,还是需要一直等待吗,还是有别的策略来解决这一问题?

带着非公平锁的这些问题,再看下公平锁源码中获锁的方式:

// java.util.concurrent.locks.ReentrantLock#FairSync static final classFairSyncextendsSync{ ... final void lock() { acquire(1); } ... }

看到这块代码,我们可能会存在这种疑问:Lock函数通过Acquire方法进行加锁,但是具体是如何加锁的呢?

结合公平锁和非公平锁的加锁流程,虽然流程上有一定的不同,但是都调用了Acquire方法,而Acquire方法是FairSync和UnfairSync的父类AQS中的核心方法。

对于上边提到的问题,其实在ReentrantLock类源码中都无法解答,而这些问题的答案,都是位于Acquire方法所在的类AbstractQueuedSynchronizer中,也就是本文的核心——AQS。下面我们会对AQS以及ReentrantLock和AQS的关联做详细介绍(相关问题答案会在2.3.5小节中解答)。

2 AQS

首先,我们通过下面的架构图来整体了解一下AQS框架:

  • 上图中有颜色的为Method,无颜色的为Attribution。

  • 总的来说,AQS框架共分为五层,自上而下由浅入深,从AQS对外暴露的API到底层基础数据。

  • 当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的API进入AQS内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层。

下面我们会从整体到细节,从流程到方法逐一剖析AQS框架,主要分析过程如下:

2.1 原理概览

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

主要原理图如下:

AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

2.1.1 AQS数据结构

先来看下AQS中最基本的数据结构——Node,Node即为上面CLH变体队列中的节点。

解释一下几个方法和属性值的含义:

方法和属性值

含义

waitStatus

当前节点在队列中的状态

thread

表示处于该节点的线程

prev

前驱指针

predecessor

返回前驱节点,没有的话抛出npe

nextWaiter

指向下一个处于CONDITION状态的节点(由于本篇文章不讲述Condition Queue队列,这个指针不多介绍)

next

后继指针

线程两种锁的模式:

模式

含义

SHARED

表示线程以共享的模式等待锁

EXCLUSIVE

表示线程正在以独占的方式等待锁

waitStatus有下面几个枚举值:

枚举

含义

0

当一个Node被初始化的时候的默认值

CANCELLED

为1,表示线程获取锁的请求已经取消了

CONDITION

为-2,表示节点在等待队列中,节点线程等待唤醒

PROPAGATE

为-3,当前线程处在SHARED情况下,该字段才会使用

SIGNAL

为-1,表示线程已经准备好了,就等资源释放了

2.1.2 同步状态State

在了解数据结构后,接下来了解一下AQS的同步状态——State。AQS中维护了一个名为state的字段,意为同步状态,是由Volatile修饰的,用于展示当前临界资源的获锁情况。

// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state;

下面提供了几个访问这个字段的方法:

方法名

描述

protected final int getState()

获取State的值

protected final void setState(int newState)

设置State的值

protected final boolean compareAndSetState(int expect, int update)

使用CAS方式更新State

这几个方法都是Final修饰的,说明子类中无法重写它们。我们可以通过修改State字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。

对于我们自定义的同步工具,需要自定义获取同步状态和释放状态的方式,也就是AQS架构图中的第一层:API层。

2.2 AQS重要方法与ReentrantLock的关联

从架构图中可以得知,AQS提供了大量用于自定义同步器实现的Protected方法。自定义同步器实现的相关方法也只是为了通过修改State字段来实现多线程的独占模式或者共享模式。自定义同步器需要实现以下方法(ReentrantLock需要实现的方法如下,并不是全部):

方法名

描述

protected boolean isHeldExclusively()

该线程是否正在独占资源。只有用到Condition才需要去实现它。

protected boolean tryAcquire(int arg)

独占方式。arg为获取锁的次数,尝试获取资源,成功则返回True,失败则返回False。

protected boolean tryRelease(int arg)

独占方式。arg为释放锁的次数,尝试释放资源,成功则返回True,失败则返回False。

protected int tryAcquireShared(int arg)

共享方式。arg为获取锁的次数,尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

protected boolean tryReleaseShared(int arg)

共享方式。arg为释放锁的次数,尝试释放资源,如果释放后允许唤醒后续等待结点返回True,否则返回False。

一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。ReentrantLock是独占锁,所以实现了tryAcquire-tryRelease。

以非公平锁为例,这里主要阐述一下非公平锁与AQS之间方法的关联之处,具体每一处核心方法的作用会在文章后面详细进行阐述。

为了帮助大家理解ReentrantLock和AQS之间方法的交互过程,以非公平锁为例,我们将加锁和解锁的交互流程单独拎出来强调一下,以便于对后续内容的理解。

加锁:

  • 通过ReentrantLock的加锁方法Lock进行加锁操作。

  • 会调用到内部类Sync的Lock方法,由于Sync#lock是抽象方法,根据ReentrantLock初始化选择的公平锁和非公平锁,执行相关内部类的Lock方法,本质上都会执行AQS的Acquire方法。

  • AQS的Acquire方法会执行tryAcquire方法,但是由于tryAcquire需要自定义同步器实现,因此执行了ReentrantLock中的tryAcquire方法,由于ReentrantLock是通过公平锁和非公平锁内部类实现的tryAcquire方法,因此会根据锁类型不同,执行不同的tryAcquire。

  • tryAcquire是获取锁逻辑,获取失败后,会执行框架AQS的后续逻辑,跟ReentrantLock自定义同步器无关。

解锁:

  • 通过ReentrantLock的解锁方法Unlock进行解锁。

  • Unlock会调用内部类Sync的Release方法,该方法继承于AQS。

  • Release中会调用tryRelease方法,tryRelease需要自定义同步器实现,tryRelease只在ReentrantLock中的Sync实现,因此可以看出,释放锁的过程,并不区分是否为公平锁。

  • 释放成功后,所有处理由AQS框架完成,与自定义同步器无关。

通过上面的描述,大概可以总结出ReentrantLock加锁解锁时API层核心方法的映射关系。

2.3 通过ReentrantLock理解AQS

ReentrantLock中公平锁和非公平锁在底层是相同的,这里以非公平锁为例进行分析。

在非公平锁中,有一段这样的代码:

// java.util.concurrent.locks.ReentrantLock static final classNonfairSyncextendsSync{ ... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } ... }

看一下这个Acquire是怎么写的:

// java.util.concurrent.locks.AbstractQueuedSynchronizer publicfinalvoidacquire(int arg){ if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

再看一下tryAcquire方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer protectedbooleantryAcquire(int arg){ throw new UnsupportedOperationException(); }

可以看出,这里只是AQS的简单实现,具体获取锁的实现方法是由各自的公平锁和非公平锁单独实现的(以ReentrantLock为例)。如果该方法返回了True,则说明当前线程获取锁成功,就不用往后执行了;如果获取失败,就需要加入到等待队列中。下面会详细解释线程是何时以及怎样被加入进等待队列中的。

2.3.1 线程加入等待队列

2.3.1.1 加入队列的时机

当执行Acquire(1)时,会通过tryAcquire获取锁。在这种情况下,如果获取锁失败,就会调用addWaiter加入到等待队列中去。

2.3.1.2 如何加入队列

获取锁失败后,会执行addWaiter(Node.EXCLUSIVE)加入等待队列,具体实现方法如下:

// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; } private final boolean compareAndSetTail(Node expect, Node update) { return unsafe.compareAndSwapObject(this, tailOffset, expect, update); }

主要的流程如下:

  • 通过当前的线程和锁模式新建一个节点。

  • Pred指针指向尾节点Tail。

  • 将New中Node的Prev指针指向Pred。

  • 通过compareAndSetTail方法,完成尾节点的设置。这个方法主要是对tailOffset和Expect进行比较,如果tailOffset的Node和Expect的Node地址是相同的,那么设置Tail的值为Update的值。

// java.util.concurrent.locks.AbstractQueuedSynchronizer static { try { stateOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state")); headOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("head")); tailOffset = unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("tail")); waitStatusOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("waitStatus")); nextOffset = unsafe.objectFieldOffset(Node.class.getDeclaredField("next")); } catch (Exception ex) { throw new Error(ex); } }

从AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是tail对应的偏移量,所以这个时候会将new出来的Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。

  • 如果Pred指针是Null(说明等待队列中没有元素),或者当前Pred指针和Tail指向的位置不同(说明被别的线程已经修改),就需要看一下Enq的方法。

// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node enq(final Node node){ for (;;) { Node t = tail; if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }

如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。

总结一下,线程获取锁的时候,过程大体如下:

  1. 当没有线程获取到锁时,线程1获取锁成功。

  2. 线程2申请锁,但是锁被线程1占有。

  1. 如果再有线程要获取锁,依次在队列中往后排队即可。

回到上边的代码,hasQueuedPredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以争取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。

// java.util.concurrent.locks.ReentrantLock publicfinalbooleanhasQueuedPredecessors(){ // The correctness of this depends on head being initialized // before tail and on head.next being accurate if the current // thread is first in queue. Node t = tail; // Read fields in reverse initialization order Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }

看到这里,我们理解一下h != t && ((s = h.next) == null || s.thread != Thread.currentThread());为什么要判断的头结点的下一个节点?第一个节点储存的数据是什么?

双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。当h != t时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了Tail指向Head,没有将Head指向Tail,此时队列中有元素,需要返回True(这块具体见下边代码分析)。 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。如果此时s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;如果s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列。

// java.util.concurrent.locks.AbstractQueuedSynchronizer#enq if (t == null) { // Must initialize if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } }

节点入队不是原子操作,所以会出现短暂的head != tail,此时Tail指向最后一个节点,而且Tail指向Head。如果Head没有指向Tail(可见5、6、7行),这种情况下也需要将相关线程加入队列中。所以这块代码是为了解决极端情况下的并发问题。

2.3.1.3 等待队列中线程出队列时机

回到最初的源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer publicfinalvoidacquire(int arg){ if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

上文解释了addWaiter方法,这个方法其实就是把对应的线程以Node的数据结构形式加入到双端队列里,返回的是一个包含该线程的Node。而这个Node会作为参数,进入到acquireQueued方法中。acquireQueued方法可以对排队中的线程进行“获锁”操作。

总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。

下面我们从“何时出队列?”和“如何出队列?”两个方向来分析一下acquireQueued源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer finalbooleanacquireQueued(final Node node, int arg){ // 标记是否成功拿到资源 boolean failed = true; try { // 标记等待过程中是否中断过 boolean interrupted = false; // 开始自旋,要么获取锁,要么中断 for (;;) { // 获取当前节点的前驱节点 final Node p = node.predecessor(); // 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点) if (p == head && tryAcquire(arg)) { // 获取锁成功,头指针移动到当前node setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。具体两个方法下面细细分析 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

注:setHead方法是把当前节点置为虚节点,但并没有修改waitStatus,因为它是一直需要用的数据。

// java.util.concurrent.locks.AbstractQueuedSynchronizer privatevoidsetHead(Node node){ head = node; node.thread = null; node.prev = null; } // java.util.concurrent.locks.AbstractQueuedSynchronizer // 靠前驱节点判断当前线程是否应该被阻塞 privatestaticbooleanshouldParkAfterFailedAcquire(Node pred, Node node){ // 获取头结点的节点状态 int ws = pred.waitStatus; // 说明头结点处于唤醒状态 if (ws == Node.SIGNAL) return true; // 通过枚举值我们知道waitStatus>0是取消状态 if (ws > 0) { do { // 循环向前查找取消节点,把取消节点从队列中剔除 node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // 设置前任节点等待状态为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }

上述方法的流程图如下:

从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。为了防止因死循环导致CPU资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire流程):

从队列中释放节点的疑虑打消了,那么又有新问题了:

  • shouldParkAfterFailedAcquire中取消节点是怎么生成的呢?什么时候会把一个节点的waitStatus设置为-1?

  • 是在什么时间释放节点通知到被挂起的线程呢?

2.3.2 CANCELLED状态节点生成

acquireQueued方法中的Finally代码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer finalbooleanacquireQueued(final Node node, int arg){ boolean failed = true; try { ... for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { ... failed = false; ... } ... } finally { if (failed) cancelAcquire(node); } }

通过cancelAcquire方法,将Node的状态标记为CANCELLED。接下来,我们逐行来分析这个方法的原理:

// java.util.concurrent.locks.AbstractQueuedSynchronizer privatevoidcancelAcquire(Node node){ // 将无效节点过滤 if (node == null) return; // 设置该节点不关联任何线程,也就是虚节点 node.thread = null; Node pred = node.prev; // 通过前驱节点,跳过取消状态的node while (pred.waitStatus > 0) node.prev = pred = pred.prev; // 获取过滤后的前驱节点的后继节点 Node predNext = pred.next; // 把当前node的状态设置为CANCELLED node.waitStatus = Node.CANCELLED; // 如果当前节点是尾节点,将从后往前的第一个非取消状态的节点设置为尾节点 // 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null if (node == tail && compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; // 如果当前节点不是head的后继节点,1:判断当前节点前驱节点的是否为SIGNAL,2:如果不是,则把前驱节点设置为SINGAL看是否成功 // 如果1和2中有一个为true,再判断当前节点的线程是否为null // 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点 if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { Node next = node.next; if (next != null && next.waitStatus <= 0) compareAndSetNext(pred, predNext, next); } else { // 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点 unparkSuccessor(node); } node.next = node; // help GC } }

当前的流程:

  • 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。

  • 根据当前节点的位置,考虑以下三种情况:

(1) 当前节点是尾节点。

(2) 当前节点是Head的后继节点。

(3) 当前节点不是Head的后继节点,也不是尾节点。

根据上述第二条,我们来分析每一种情况的流程。

当前节点是尾节点。

当前节点是Head的后继节点。

当前节点不是Head的后继节点,也不是尾节点。

通过上面的流程,我们对于CANCELLED节点状态的产生和变化已经有了大致的了解,但是为什么所有的变化都是对Next指针进行了操作,而没有对Prev指针进行操作呢?什么情况下会对Prev指针进行操作?

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。 shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0);

2.3.3 如何解锁

我们已经剖析了加锁过程中的基本流程,接下来再对解锁的基本流程进行分析。由于ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:

// java.util.concurrent.locks.ReentrantLock publicvoidunlock(){ sync.release(1); }

可以看到,本质释放锁的地方,是通过框架来完成的。

// java.util.concurrent.locks.AbstractQueuedSynchronizer publicfinalbooleanrelease(int arg){ if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }

在ReentrantLock里面的公平锁和非公平锁的父类Sync定义了可重入锁的释放锁机制。

// java.util.concurrent.locks.ReentrantLock.Sync // 方法返回当前锁是不是没有被线程持有 protectedfinalbooleantryRelease(int releases){ // 减少可重入次数 int c = getState() - releases; // 当前线程不是持有锁的线程,抛出异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; // 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }

我们来解释下述源码:

// java.util.concurrent.locks.AbstractQueuedSynchronizer publicfinalbooleanrelease(int arg){ // 上边自定义的tryRelease如果返回true,说明该锁没有被任何线程持有 if (tryRelease(arg)) { // 获取头结点 Node h = head; // 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态 if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }

这里的判断条件为什么是h != null && h.waitStatus != 0?

h == null Head还没初始化。初始情况下,head == null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。

h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。

h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。

再看一下unparkSuccessor方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer privatevoidunparkSuccessor(Node node){ // 获取头结点waitStatus int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); // 获取当前节点的下一个节点 Node s = node.next; // 如果下个节点是null或者下个节点被cancelled,就找到队列最开始的非cancelled的节点 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); }

为什么要从后往前找第一个非Cancelled的节点呢?原因如下。

之前的addWaiter方法:

// java.util.concurrent.locks.AbstractQueuedSynchronizer private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; }

我们从这里可以看到,节点入队并不是原子操作,也就是说,node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作Tail入队的原子操作,但是此时pred.next = node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。还有一点原因,在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node。

综上所述,如果是从前往后找,由于极端情况下入队的非原子操作和CANCELLED节点产生过程中断开Next指针的操作,可能会导致无法遍历所有的节点。所以,唤醒对应的线程后,对应的线程就会继续往下执行。继续执行acquireQueued方法以后,中断如何处理?

2.3.4 中断恢复后的执行流程

唤醒后,会执行return Thread.interrupted();,这个函数返回的是当前执行线程的中断状态,并清除。

// java.util.concurrent.locks.AbstractQueuedSynchronizer private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }

再回到acquireQueued代码,当parkAndCheckInterrupt返回True或者False的时候,interrupted的值不同,但都会执行下次循环。如果这个时候获取锁成功,就会把当前interrupted返回。

// java.util.concurrent.locks.AbstractQueuedSynchronizer finalbooleanacquireQueued(final Node node, int arg){ boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

如果acquireQueued为True,就会执行selfInterrupt方法。

// java.util.concurrent.locks.AbstractQueuedSynchronizer static void selfInterrupt() { Thread.currentThread().interrupt(); }

该方法其实是为了中断线程。但为什么获取了锁以后还要中断线程呢?这部分属于Java提供的协作式中断知识内容,感兴趣同学可以查阅一下。这里简单介绍一下:

  1. 当中断线程被唤醒时,并不知道被唤醒的原因,可能是当前线程在等待中被中断,也可能是释放了锁以后被唤醒。因此我们通过Thread.interrupted()方法检查中断标记(该方法返回了当前线程的中断状态,并将当前线程的中断标识设置为False),并记录下来,如果发现该线程被中断过,就再中断一次。

  2. 线程在等待资源的过程中被唤醒,唤醒后还是会不断地去尝试获取锁,直到抢到锁为止。也就是说,在整个流程中,并不响应中断,只是记录中断记录。最后抢到锁返回了,那么如果被中断过的话,就需要补充一次中断。

这里的处理方式主要是运用线程池中基本运作单元Worder中的runWorker,通过Thread.interrupted()进行额外的判断处理,感兴趣的同学可以看下ThreadPoolExecutor源码。

2.3.5 小结

我们在1.3小节中提出了一些问题,现在来回答一下。

Q:某个线程获取锁失败的后续流程是什么呢?

A:存在某种排队等候机制,线程继续等待,仍然保留获取锁的可能,获取锁流程仍在继续。

Q:既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

A:是CLH变体的FIFO双端队列。

Q:处于排队等候机制中的线程,什么时候可以有机会获取锁呢?

A:可以详细看下2.3.1.3小节。

Q:如果处于排队等候机制中的线程一直无法获取锁,需要一直等待么?还是有别的策略来解决这一问题?

A:线程所在节点的状态会变成取消状态,取消状态的节点会从队列中释放,具体可见2.3.2小节。

Q:Lock函数通过Acquire方法进行加锁,但是具体是如何加锁的呢?

A:AQS的Acquire会调用tryAcquire方法,tryAcquire由各个自定义同步器实现,通过tryAcquire完成加锁过程。

3 AQS应用

3.1 ReentrantLock的可重入应用

ReentrantLock的可重入性是AQS很好的应用之一,在了解完上述知识点以后,我们很容易得知ReentrantLock实现可重入的方法。在ReentrantLock里面,不管是公平锁还是非公平锁,都有一段逻辑。

公平锁:

// java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; }

非公平锁:

// java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire if (c == 0) { if (compareAndSetState(0, acquires)){ setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; }

从上面这两段都可以看到,有一个同步状态State来控制整体可重入的情况。State是Volatile修饰的,用于保证一定的可见性和有序性。

// java.util.concurrent.locks.AbstractQueuedSynchronizer private volatile int state;

接下来看State这个字段主要的过程:

  1. State初始化的时候为0,表示没有任何线程持有锁。

  2. 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。

  3. 解锁也是对这个字段-1,一直到0,此线程对锁释放。

3.2 JUC中的应用场景

除了上边ReentrantLock的可重入性的应用,AQS作为并发编程的框架,为很多其他同步工具提供了良好的解决方案。下面列出了JUC中的几种同步工具,大体介绍一下AQS的应用场景:

同步工具

同步工具与AQS的关联

ReentrantLock

使用AQS保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock记录当前获得锁的线程标识,用于检测是否重复获取,以及错误线程试图解锁操作时异常情况的处理。

Semaphore

使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。

CountDownLatch

使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。

ReentrantReadWriteLock

使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。

ThreadPoolExecutor

Worker利用AQS同步状态实现对独占线程变量的设置(tryAcquire和tryRelease)。

3.3 自定义同步工具

了解AQS基本原理以后,按照上面所说的AQS知识点,自己实现一个同步工具。

public classLeeLock{ private static classSyncextendsAbstractQueuedSynchronizer{ @Override protectedbooleantryAcquire(int arg){ return compareAndSetState(0, 1); } @Override protectedbooleantryRelease(int arg){ setState(0); return true; } @Override protectedbooleanisHeldExclusively(){ return getState() == 1; } } private Sync sync = new Sync(); publicvoidlock(){ sync.acquire(1); } publicvoidunlock(){ sync.release(1); } }

通过我们自己定义的Lock完成一定的同步功能。

public class LeeMain { static int count = 0; static LeeLock leeLock = new LeeLock(); publicstaticvoidmain (String[] args) throws InterruptedException { Runnable runnable = new Runnable() { @Override publicvoidrun () { try { leeLock.lock(); for (int i = 0; i < 10000; i++) { count++; } } catch (Exception e) { e.printStackTrace(); } finally { leeLock.unlock(); } } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } }

上述代码每次运行结果都会是20000。通过简单的几行代码就能实现同步功能,这就是AQS的强大之处。

总结

我们日常开发中使用并发的场景太多,但是对并发内部的基本框架原理了解的人却不多。由于篇幅原因,本文仅介绍了可重入锁ReentrantLock的原理和AQS原理,希望能够成为大家了解AQS和ReentrantLock等同步器的“敲门砖”。

 
posted @ 2020-06-27 12:52  小虎。。。。  阅读(166)  评论(0)    收藏  举报