多线程

多线程

基础知识

多线程常见术语

串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。串行相当于2个人排队使用1台电脑。

并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。并行相当于2个人分配了2台电脑。

并发:多个任务在同一个CPU核上,按细分的时间片轮流交替执行,从逻辑上来看那些任务是同时执行。并发相当于2个人用1台电脑。

进程:一个在内存中运行的应用程序,每个正在系统上运行的程序都是一个进程。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。

线程:进程中的一个执行任务(控制单元),它负责在程序里独立执行。

上下文切换:当前任务在执行完CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。Linux相比与其他操作系统(包括其他类Unix系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

用户 (User) 线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程。

守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是JVM中非守护线程的佣人。一旦所有用户线程都结束运行,守护线程会随JVM一起结束工作。

线程同步和异步:在Java中,线程同步和异步的区别主要在于发送请求后是否需要等待返回,对于线程同步来说,发送请求后需要等待返回,等待返回后才能继续发送下一个请求,而线程异步是不需要等待返回的,在发送一个请求后随时可发送下一个请求。

阻塞式方法:指程序会一直等待该方法完成期间不做其他事情,ServerSocket的accept()方法就是一直等待客户端连接。这里的阻塞是指调用结果返回之前,当前线程会被挂起,直到得到结果之后才会返回。此外,还有异步和非阻塞式方法在任务完成前就返回。

Java内存模型:Java内存模型简称JMM,定义了一个线程对另一个线程可见。共享变量存放在主内存中,每个线程都有自己的本地内存,当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题。

多线程常用方法

方法名 描述
sleep() 强迫一个线程睡眠N毫秒
isAlive() 判断一个线程是否存活
join() 等待线程终止
activeCount() 程序中活跃的线程数
enumerate() 枚举程序中的线程
currentThread() 得到当前线程
isDaemon() 一个线程是否为守护线程
setDaemon() 设置一个线程为守护线程
setName() 为线程设置一个名称
wait() 强迫一个线程等待
notify() 通知一个线程继续运行
setPriority() 设置一个线程的优先级

查找CPU使用率最高线程

Windows直接用任务管理器查看,Linux下可以用top命令查看。
1)先找出CPU耗用厉害的进程pid,终端执行top命令,然后按下shift+p (shift+m是找出消耗内存最高)查找出CPU利用最厉害的pid号。
2)根据上面第一步拿到的pid号,top -H -p pid(比如top -H -p 1328)。然后按下shift+p,查找出CPU利用率最厉害的线程号,使用在线转换器将获取到的线程号转换成16进制。
3)使用jstack工具将进程信息打印输出
jstack pid号 > /tmp/t.dat,比如jstack 31365 > /tmp/t.dat
4)编辑/tmp/t.dat文件,查找线程号对应的信息
5)或者直接使用JDK自带的工具jconsole、visualVm查看,这都是JDK自带的,可以直接在JDK的bin目录下找到直接使用。

线程调度与优先级

计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待CPU分配执行,JAVA虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU的使用权。Java是由JVM中的线程计数器来实现线程调度。有两种调度模型:
1)分时调度模型:是指让所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用的CPU的时间片这个也比较好理解。
2)Java虚拟机采用抢占式调度模型:是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU。Java设置优先级可以通过setPriority()方法设置。

多种创建线程方式

1)继承Thread类

/**
 * @author XDZY
 * @date 2020/01/14 12:51
 * @description 创建多线程的方式一
 * 继承java.lang.Thread类
 * java属于抢占式调度,所以主线程和另一个线程执行顺序是随机的,并且我们是无法控制CPU的
 */
public class Demo02 {
    public static void main(String[] args) {
        //3)创建Thread的子类对象
        MyThread myThread = new MyThread();
        //4)调用start方法启动线程,执行run方法
        //为什么不直接执行run方法
        //多线程原理:如果直接执行run方法,则run方法与主线程在同一栈中执行,就是单线程;
        //而调用start方法,则会通过操作系统创建一个新的线程,开辟一个新的栈空间执行run方法,从而主线程与其他线程之间不会相互影响,这就是多线程
        myThread.start();

        for (int i = 0; i < 20; i++) {
            System.out.println("main" + i);
        }
    }
}

//1)创建一个Thread的子类
class MyThread extends Thread {
    //2)重写run方法,设置该线程需要处理的事情
    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            System.out.println("run" + i);
        }
    }
}

2)实现Runnable接口

/**
 * @author XDZY
 * @date 2020/01/14 13:49
 * @description 创建多线程的方式二
 * 实现java.lang.Runnable接口
 */
public class Demo04 {
    public static void main(String[] args) {
        //3)创建Runnable实现类对象
        Runnable runnable = new RunnableImpl();
        //4)通过thread构造方法传递实现类对象
        Thread thread = new Thread(runnable);
        //5)调用start方法启动线程,执行run方法
        thread.start();

        for (int i = 0; i < 20; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
}

//1)创建Runnable实现类
class RunnableImpl implements Runnable {
    //2)重写run方法
    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
}

3)实现Callable接口

/**
 * @author XDZY
 * @date 2021年7月19日 13:43:58
 * @description 创建多线程的方式三
 * 实现Callable接口,开启有返回值的多线程,Runnable的run方法是没有返回值的
 */
public class Demo05_1 {
    public static void main(String[] args) {
        ExecutorService service = Executors.newSingleThreadExecutor();
        Future<String> future = service.submit(new Callable() {
            /**
             * call方法返回String
             */
            @Override
            public String call() throws Exception {
                String name = Thread.currentThread().getName();
                return "通过实现Callable接口,线程名称:" + name;
            }
        });
        try {
            String result = future.get();
            System.out.println(result);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

4)匿名内部类创建线程

/**
 * @author XDZY
 * @date 2020/01/14 14:05
 * @description 匿名内部类创建多线程
 * 在其他类中创建一个没有名字的类,一般是一个类的子类或是一个接口的实现类
 */
public class Demo05 {
    public static void main(String[] args) {
        //匿名内部类创建多线程一
        new Thread() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ":继承方式");
            }
        }.start();

        //匿名内部类创建多线程二
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ":实现方式");
            }
        };
        new Thread(runnable).start();

        //匿名内部类创建多线程三
        new Thread(new Runnable() {
            @Override
            public void run () {
                System.out.println(Thread.currentThread().getName() + ":实现方式简化");
            }
        }).start();
    }
}

线程的状态转换(生命周期)

参考图例一

img

参考图例二

4840092-f85e70e2262b7878.webp

线程完整生命周期说明:

新建(new):新创建了一个线程对象。表示线程新建出来还没有被启动的状态,比如:Thread t = new MyThread();

就绪/运行状态(runnable):该状态包含了经典线程模型的两种状态,就绪(Ready)、运行(Running)。
就绪(Ready):线程对象创建后,当调用线程对象的start()方法,该线程处于就绪状态,等待被线程调度选中,获取CPU的使用权。
运行(running):就绪的线程获得了CPU时间片,执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。

阻塞(block):通常与锁有关系,表示线程正在获取有锁控制的资源,比如进入synchronized代码块,获取ReentryLock等;发起阻塞式IO也会阻塞,比如字符流字节流操作。处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。阻塞的情况分三种:
1)等待阻塞:运行状态中的线程执行wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态。
2)同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态。
3)其他阻塞: 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

等待(WAITING):线程在等待某种资源就绪。触发等待原因可能如下:
1)当一个线程执行了Object.wait()的时候,它一定在等待另一个线程执行Object.notify()或者Object.notifyAll()。
2)一个线程thread,其在主线程中被执行了thread.join()的时候,主线程即会等待该线程执行完成。
3)当一个线程执行了LockSupport.park()的时候,其在等待执行LockSupport.unpark(thread)。

超时等待(TIMED_WAIT):线程进入条件和等待类似,但是它调用的是带有超时时间的方法。触发超时等待原因可能如下方法(该状态不同于WAITING,它可以在指定的时间后自行返回):
1)Object.wait(long)
2)Thread.join(long)
3)LockSupport.parkNanos()
4)LockSupport.parkUntil()
5)Thread.sleep(long)

死亡/结束(dead):线程正常退出或异常退出后,就处于终结状态。也可以叫线程的死亡。线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期,死亡的线程不可再次复生。

扩展说明:
经典线程模型包含5种状态:新建、就绪、运行、等待、退出
经典线程的就绪、运行,在Java里面统一叫RUNNABLE
Java的BLOCKED、WAITING、TIMED_WAITING都属于传统模型的等待状态

ThreadLocal

/**
 * @author XDZY
 * @date 2022/06/07 15:50
 * @description 线程变量ThreadLocal
 * 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
 */
public class Demo19 {
    private static int num = 0;

    /**
     * 初始化一个默认为0的线程变量
     */
    private static final ThreadLocal<Integer> threadNum = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };

    /**
     * 初始化一个默认为hello的线程变量
     */
    private static final ThreadLocal<String> threadStr = ThreadLocal.withInitial(() -> "hello");

    /**
     * 每个线程获取到的num值是不确定的
     */
    @Test
    public void initNum() {
        Thread[] threads = new Thread[5];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                num += 5;
                System.out.println(Thread.currentThread().getName() + ":" + num);
            }, "thread-" + i);
        }
        for (Thread thread : threads) {
            thread.start();
        }
    }

    /**
     * 每个线程获取到的threadNum值都是0
     */
    @Test
    public void initThreadNum() {
        Thread[] threads = new Thread[5];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                /**
                 * 获取线程变量初始值
                 * 原理分析:根据当前线程对象获取一个ThreadLocalMap,没有则创建;
                 * ThreadLocalMap的键为当前线程变量对象,值为线程变量值。
                 */
                Integer num = threadNum.get();
                num = num + 5;
                threadNum.set(num);

                /**
                 * 原理分析:根据当前线程获取一个ThreadLocalMap,前面如果已经创建,继续获取发现当前线程变量的值不存在,
                 * 则在同一个ThreadLocalMap里面添加当前线程变量的值。
                 * ThreadLocalMap(底层是一个Entry[]数组)里面储存的对象hashCode可能相同,所以
                 * map.set(this, value)方法使用斐波那契散列法(算法结果不重复)设置下标值防止了Hash碰撞的发生。
                 */
                String str = threadStr.get();
                str = str + "word";
                /**
                 * 原理分析:根据当前线程对象获取一个ThreadLocalMap,没有则创建;
                 * 存在ThreadLocalMap时,通过斐波那契散列法获取当前变量在Entry[]的下标,
                 * 即可获取到数组里当前线程变量的Entry对象(该对象为弱引用,为空会被GC回收),
                 * 如果存在则覆盖值,如果为空则替换当前位置的值,即赋值,如果下标超出则扩容。
                 */
                threadStr.set(str);

                System.out.println(Thread.currentThread().getName() + ":" + threadNum.get() + "->>>" + threadStr.get());
            }, "thread-" + i);
        }
        for (Thread thread : threads) {
            thread.start();
        }
    }
}

线程安全

如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替运行,并且在主调试代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,则称这个类是线程安全的。线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。Servlet不是线程安全的,Servlet是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。Struts2的action是多实例多线程的,是线程安全的,每个请求过来都会new一个新的action分配给这个请求,请求完成后销毁。SpringMVC的Controller也不是线程安全的,和Servlet类似的处理流程。Struts2好处是不用考虑线程安全问题,Servlet和SpringMVC需要考虑线程安全问题,但是性能可以提升不用处理太多的gc,可以使用ThreadLocal来处理多线程的问题。

并发编程三要素

1)原子性:原子即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
2)可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized、volatile)
3)有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)

要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

保证线程安全方式

1)线程切换带来的原子性问题解决办法:使用多线程之间同步synchronized或使用lock锁。
2)缓存导致的可见性问题解决办法:synchronized、volatile、LOCK,这些可以解决可见性问题。
3)编译优化带来的有序性问题解决办法:Happens-Before规则可以解决有序性问题。

死锁

img

死锁说明

死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止。

死锁的四个必要条件:
1)互斥条件:在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,就只能等待,直至占有资源的进程用毕释放。
2)占有且等待条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
3)不可抢占条件:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
4)循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。比如一个进程集合,A在等B,B在等C,C在等A。

避免死锁的发生:避免一个线程同时获得多个锁;避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源;尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。

volatile

/**
 * @author XDZY
 * @date 2022/06/08 16:31
 * @description volatile(/ˈvɑːlətl/)关键字
 */
public class Demo20 {
    private volatile static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            int i = 0;
            // 在不同CPU多线程的情况下,这个stop的值可能不会被改变,
            // 即当前线程改变之后,别的线程还是false,加上volatile后则会更新到主内存,别的线程可以获取到true值
            while (!stop) {
                i++;
            }
        }).start();

        Thread.sleep(1000);
        stop = true;
    }
}

volatile解析:

1)volatile(/ˈvɑːlətl/)关键字保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存(其他线程优先从自己CPU的高速缓存读取数据,如果主存更新,高速缓存数据也会更新)。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。Java内存模型(JMM)通过设置内存屏障实现可见性。volatile禁止进行指令重排序。

2)高速缓存和重排序会影响可见性
高速缓存:在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。通过在总线加LOCK锁的方式(效率低下)或缓存一致性协议(Intel的MESI协议)可以在硬件层面上解决一致性问题。

指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

了解更多:https://www.cnblogs.com/guanghe/p/9206635.html

线程同步

CAS

CAS(Compare and Swap 比较并交换),是一种无锁算法,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

1)使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

2)CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V内存地址存放的实际值;O预期的值(旧值);N更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。

3)元老级的 Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

4)ABA问题:因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。

5)自旋时间过长:使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

synchronized

在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。synchronized可以修饰类、方法、变量。synchronized关键字加到static静态方法和synchronized(class)代码块上都是是给Class类上锁。synchronized关键字加到实例方法上是给对象实例上锁。尽量不要使用synchronized(String a),因为在JVM中,字符串常量池具有缓存功能。
1)修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
/**
 * @author XDZY
 * @date 2020/01/14 14:20
 * @description 解决线程安全问题方式一,同步代码块
 */
public class Demo07 {
    public static void main(String[] args) {
        Runnable runnable = new RunnableImpl3();
        //设置3个线程买同一批票
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}

//多线程共享数据
class RunnableImpl3 implements Runnable {
    //定义票数
    int ticketNum = 100;

    //创建一个锁对象
    Object object = new Object();

    @Override
    public void run() {
        while (true) {
            /**
             * 同步代码块,锁住可能出现线程安全的代码
             * 作用范围涉及所有调用该方法的对象
             * 锁对象在堆中的储存状态:对象头+实例数据+填充,其中锁的相关信息(锁标记、偏向锁标记等等)储存在对象头中
             */
            synchronized (object) {
                if (ticketNum > 0) {
                    //提高出现问题概率
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    System.out.println(Thread.currentThread().getName() + ":" + ticketNum);
                    ticketNum--;
                }
            }
        }
    }
}

2)修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
    
3)修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。
/**
 * @author XDZY
 * @date 2020/01/14 14:20
 * @description 解决线程安全问题方式二,同步方法
 */
public class Demo08 {
    public static void main(String[] args) {
        Runnable runnable = new RunnableImpl4();
        //设置3个线程买同一批票
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}

//多线程共享数据
class RunnableImpl4 implements Runnable {
    //定义票数
    int ticketNum = 100;

    //定义静态票数
    static int ticketNum2 = 100;

    //定义一个同步方法(该方法的锁对象就是this,即RunnableImpl4)
    private synchronized void payTicket() {
        if (ticketNum > 0) {
            //提高出现问题概率
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName() + ":" + ticketNum);
            ticketNum--;
        }
    }

    //定义一个静态同步方法(该方法的锁对象不是this,而是本类的class属性[即class文件对象])
    private static synchronized void payTicket2() {
        if (ticketNum2 > 0) {
            //提高出现问题概率
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName() + ":" + ticketNum2);
            ticketNum2--;
        }
    }

    @Override
    public void run() {
        while (true) {
            //作用范围涉及到当前的对象,即创建不同的对象调用该方法不会等待
            //payTicket();

            //作用范围涉及到不同的对象,即创建不同的对象调用该静态方法会等待
            payTicket2();
        }
    }
}

ReentrantLock

由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比java语言的关键字synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
1)等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。
2)公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁是非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
3)锁绑定多个条件,一个ReentrantLock对象可以同时绑定多个对象。
4)synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须调用unlock()方法释放锁,因此一般会在finally块中释放锁。

ReentrantLock源码解析:
1)获取锁(lock.lock())
创建ReentrantLock锁即默认创建了一个NonfairSync非公平锁对象,该对象继承于Sync,Sync继承于AbstractQueuedSynchronizer(AQS),最终会调用AQS的nonfairTryAcquire方法,该方法会判断当前线程是否能够获取锁,不能获取锁则会进入队列等待。在AQS类中维护了一个使用双向链表Node实现的FIFO队列(这是一个虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)用于保存等待的线程,同时利用Volatile修饰的一个int类型的state来表示状态,使用时通过继承AQS类并实现它的acquire(获取锁)和release(释放锁)方法来操作状态,来实现线程的同步。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入锁的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
//获取锁方法源码
final void lock() {
    //先尝试cas乐观锁去获取锁,获取不到,进入等待
    //State锁标记默认为0,大于等于1时表示有锁,之所以大于等于1是因为可重入
    if (compareAndSetState(0, 1))
    	setExclusiveOwnerThread(Thread.currentThread());
    else
    	acquire(1);
}

//acquire(/əˈkwaɪər/)方法的具体实现
public final void acquire(int arg) {
    //等待方法:会先再次尝试cas乐观锁去获取锁,可能其他线程会释放,
    //获取不到时,会判断是不是同一个线程,是则状态加1(可重入锁),不是,则该线程进入自旋挂起,并加入到队列等待。
    //AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配,在双向链表Node实现的FIFO队列中,
    //head指向当前运行的Node节点(线程为null,状态为0的一个节点),tail指向链表尾节点。
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
    }
}

2)释放锁(lock.unlock())
设置当前头节点线程状态为1,然后唤醒(解除中断)当前头节点线程的下一个节点,删除前后节点连接,将之前头结点从队列移除,新节点会尝试获取锁,获取不到则会继续挂起,如果下一个节点为空,则链表从后往前遍历找到最开始非空的节点,从后往前是为了防止之前断开头结点时链接缺失。

ReentrantLock读写锁

/**
 * @author XDZY
 * @date 2022/06/15 23:40
 * @description ReentrantLock读写锁
 */
public class Demo09_1 {
    //获取读写锁对象
    static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    //模拟缓存
    static Map<String, Object> cacheMap = new HashMap<>();

    //获取线程读写锁
    static Lock read = readWriteLock.readLock();
    static Lock write = readWriteLock.writeLock();

    /**
     * 读取数据
     *
     * @param key
     * @return
     */
    public static Object get(String key) {
        System.out.println("begin read data:" + key);
        //获得读锁
        read.lock();
        try {
            return cacheMap.get(key);
        } finally {
            read.unlock();
        }
    }

    /**
     * 写入数据
     *
     * @param key
     * @param val
     * @return
     */
    public static Object put(String key, Object val) {
        //获得写锁
        write.lock();
        try {
            Object obj = cacheMap.put(key, val);
            Thread.sleep(10000);
            return obj;
        } catch (InterruptedException e) {
            System.out.println("数据正在写入。。。。。。。。");
        } finally {
            write.unlock();
        }
        return null;
    }

    public static void main(String[] args) {
        new Thread(() -> {
            put("name", "李三");
        }).start();

        new Thread(() -> {
            System.out.println(get("name"));
        }).start();
    }
}

AQS

java.util.concurrent:JUC,并发工具包,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。
AbstractQueuedSynchronizer(AQS):同步工具,包含独占锁(互斥锁)和共享锁(读写锁)
AQS原理解析参考文献:
https://juejin.cn/post/6844903601538596877
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

AbstractQueuedSynchronizer(AQS):同步工具,包含独占锁(互斥锁)和共享锁(读写锁),同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者的关系:锁是面向使用者,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待和唤醒等底层操作。锁和同步器很好的隔离了使用者和实现者所需关注的领域。

AQS之双向同步队列

image-20220824170158879

队列说明:

在AQS类中维护了一个使用双向链表Node实现的FIFO(先进先出)队列(这是一个虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),用于保存等待的线程,同时利用Volatile修饰的一个int类型的state来表示状态,使用时通过继承AQS类并实现它的acquire(获取锁)和release(释放锁)方法来操作状态,来实现线程的同步。Node其中有这样一些属性:
volatile int waitStatus //节点状态 
volatile Node prev //当前节点/线程的前驱节点 
volatile Node next; //当前节点/线程的后继节点 
volatile Thread thread;//加入同步队列的线程引用 
Node nextWaiter;//等待队列中的下一个节点

节点的状态有以下这些:
int CANCELLED =  1//节点从同步队列中取消 
int SIGNAL    = -1//后继节点的线程处于等待状态,如果当前节点释放同步状态会通知后继节点,使得后继节点的线程能够运行; 
int CONDITION = -2//当前节点进入等待队列中 
int PROPAGATE = -3//表示下一次共享式同步状态获取将会无条件传播下去 
int INITIAL = 0;//初始状态

另外AQS中有两个重要的成员变量:
private transient volatile Node head;
private transient volatile Node tail;
也就是说AQS实际上通过头尾指针来管理同步队列,同时实现包括获取锁失败的线程进行入队,释放锁时对同步队列中的线程进行通知等核心方法。

AQS之lock源码解析

image-20220824174913000

lock源码解析:

//获取锁方法源码
final void lock() {
    //先尝试cas乐观锁去获取锁,获取不到,进入等待
    //State锁标记默认为0,大于等于1时表示有锁,之所以大于等于1是因为可重入
    if (compareAndSetState(0, 1))
    	setExclusiveOwnerThread(Thread.currentThread());
    else
    	acquire(1);
}

//acquire(/əˈkwaɪər/)方法的具体实现
public final void acquire(int arg) {
    //等待方法:会先再次尝试cas乐观锁去获取锁,可能其他线程会释放,
    //获取不到时,会判断是不是同一个线程,是则状态加1(可重入锁),不是,则该线程进入自旋挂起,并加入到队列等待。
    //AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配,在双向链表Node实现的FIFO队列中,head指向当前运行的Node节点(线程为null,状态为0的一个节点),tail指向链表尾节点。
    if (!tryAcquire(arg) &&
    acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    	selfInterrupt();
}

带头节点的队列初始化:在tail为null时,即当前线程是第一次插入同步队列。compareAndSetTail(t, node)方法会利用CAS操作设置尾节点,如果CAS操作失败会在for(;;)死循环中不断尝试,直至成功return返回为止。即在当前线程是第一个加入同步队列时,调用compareAndSetHead(new Node())方法,完成链式队列的头结点的初始化,并且自旋不断尝试CAS尾插入节点直至成功为止。
/**
如果当前节点的前驱节点是头节点,并且能够获得同步状态的话,当前线程能够获得锁该方法执行结束退出;获取锁失败的话,先将前驱节点状态设置成SIGNAL,然后调用LookSupport.park方法使得当前线程阻塞。
*/
final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
			   // 1. 获得当前节点的先驱节点
                final Node p = node.predecessor();
			   // 2. 当前节点能否获取独占式锁					
			   // 2.1 如果当前节点的先驱节点是头结点并且成功获取同步状态,即可以获得独占式锁
                if (p == head && tryAcquire(arg)) {
				   //队列头指针用指向当前节点
                    setHead(node);
				   //释放前驱节点
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
			   // 2.2 获取锁失败,线程进入等待状态等待获取独占式锁
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

获取锁成功,出队操作:将当前节点通过setHead()方法设置为队列的头结点,然后将之前的头结点的next域设置为null并且prev域也为null,即与队列断开,无任何引用方便GC能够将内存进行回收。

出队示例图:

image-20220824173756849

AQS之unlock源码解析

原理分析:设置当前头节点线程状态为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;
}

线程通信

sleep、wait、yield

1)sleep()方法是Thread的静态方法,而wait是Object实例方法,简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中,因为锁属于对象。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。
2)wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。而sleep()方法没有这个限制可以在任何地方使用。另外,wait()方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁。
3)sleep()方法在休眠时间达到后如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notift/Object.notifyAll通知唤醒后,才会离开等待池,并且再次获得CPU时间片才会继续执行。
4)sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会,yield()方法只会给相同优先级或更高优先级的线程以运行的机会。
5)线程执行sleep()方法后转入阻塞blocked状态,而执行yield()方法后转入就绪ready状态。
6)sleep()方法声明抛出InterruptedException,而yield()方法没有声明任何异常。
7)sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。

/**
 * @author XDZY
 * @date 2020/01/14 15:58
 * @description 等待唤醒案例(线程之间的通信)
 */
public class Demo10 {
    /**
     * 顾客买包子,老板花5秒做包子
     *
     * @param args
     */
    public static void main(String[] args) {
        //创建一个唯一的锁对象
        Object object = new Object();

        //顾客线程
        new Thread() {
            @Override
            public void run() {
                //同步代码块,保证等待和唤醒线程只有一个执行
                synchronized (object) {
                    System.out.println("老板,你好,我需要包子!");
                    try {
                        //进入无限等待状态
                        object.wait();
                        //5秒之后自动唤醒
                        //object.wait(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //唤醒之后执行
                    System.out.println("好的,谢谢!");
                }
            }
        }.start();

        //老板线程
        new Thread() {
            @Override
            public void run() {
                try {
                    //花5秒做包子
                    sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //同步代码块,保证等待和唤醒线程只有一个执行
                synchronized (object) {
                    System.out.println("你好,你的包子做好了!");
                    //唤醒顾客线程,如果有多个顾客线程,将会唤醒等待时间最久的那个
                    //必须由同一个锁对象唤醒
                    object.notify();
                    //唤醒所有等待的线程
                    //object.notifyAll();
                }
            }
        }.start();
    }
}

interrupt

在java中有以下3种方法可以终止正在运行的线程:
1)使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。
2)使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。
3)使用interrupt方法中断线程。
中断可以理解为线程的一个标志位,它表示了一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了一个招呼,其他线程可以调用该线程的interrupt()方法对其进行中断操作,同时该线程可以调用isInterrupted()来感知其他线程对其自身的中断操作,从而做出响应。另外,同样可以调用Thread的静态方法interrupted()对当前线程进行中断操作,该方法会清除中断标志位。需要注意的是,当抛出InterruptedException时候,会清除中断标志位,也就是说在调用isInterrupted()会返回false。在结束线程时通过中断标志位或者标志位的方式可以有机会去清理资源,相对于武断而直接的结束线程(stop方法),这种方式要优雅和安全。

/**
 * @author XDZY
 * @date 2021/07/19 14:05
 * @description 线程中断interrupt(/ˌɪntəˈrʌpt/)
 */
public class InterruptDemo {
    public static void main(String[] args) throws InterruptedException {
        //sleepThread睡眠1s
        final Thread sleepThread = new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    //所有阻塞的操作都会抛出InterruptedException异常
                    //TODO 由于阻塞时中断,可能会存在唤醒或睡眠结束的情况,这时会清除标识位(即isInterrupted==false),
                    // 所以一般会在中断时抛出异常这里进行break退出循环或结束线程操作。
                    e.printStackTrace();
                }
                super.run();
            }
        };

        //busyThread一直执行死循环
        Thread busyThread = new Thread() {
            @Override
            public void run() {
                while (true) {
                }
            }
        };

        //复位操作
        Thread returnThread = new Thread() {
            @Override
            public void run() {
                while (true) {
                    if (Thread.currentThread().isInterrupted()) {
                        try {
                            System.out.println("手动复位之前: " + Thread.currentThread().isInterrupted());//true
                            //手动回到初始状态,中断之后该线程即结束被回收,这里会重新开始新的线程
                            Thread.interrupted();
                            System.out.println("手动复位之后: " + Thread.currentThread().isInterrupted());//false
                        } catch (Exception e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }
        };

        //中断即结束测试
        Thread interrupThread = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 2; i++) {
                    //手动回到初始状态,中断之后该线程即结束被回收,这里会重新开始新的线程
                    //第一次的线程被停止,其他2个线程重新执行
                    Thread.interrupted();
                    System.out.println("未中断的线程:" + Thread.currentThread().getName() + ",执行次数:"+i);
                }
            }
        };

        //=====================================开始启动线程并且中断=====================================
        sleepThread.start();
        busyThread.start();
        returnThread.start();
        interrupThread.start();
        //线程中断
        sleepThread.interrupt();
        busyThread.interrupt();
        returnThread.interrupt();
        interrupThread.interrupt();
        //=====================================开始启动线程并且中断=====================================

        //可以通过中断的方式实现线程间的简单交互,while (sleepThread.isInterrupted()) 表示在Main中会持续监测sleepThread,
        //一旦sleepThread的中断标志位清零,时间到了之后,即sleepThread.isInterrupted()返回为false时Main线程才会继续往下执行
        while (sleepThread.isInterrupted()) {
            System.out.println(sleepThread.isInterrupted());//true
        }
        //sleepThread抛出InterruptedException后清除标志位
        System.out.println("sleepThread isInterrupted: " + sleepThread.isInterrupted());//false
        //busyThread不会清除标志位
        System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted());//true
    }
}

join

join方法可以看做是线程间协作的一种方式,很多时候,一个线程的输入可能非常依赖于另一个线程的输出,这就像两个好基友,一个基友先走在前面突然看见另一个基友落在后面了,这个时候他就会在原处等一等这个基友,等基友赶上来后,就两人携手并进。如果一个线程实例A执行了threadB.join(),其含义是:当前线程A会等待threadB线程终止后threadA才会继续执行。
实现原理:通过wait进行阻塞,再通过notify进行唤醒,这之后执行的线程必须等待之前线程执行完才能继续执行,这期间会建立一个happens-before规则。

/**
 * @author XDZY
 * @date 2022/06/08 22:12
 * @description 线程有序执行
 */
public class JoinDemo02 {
    public static void main(String[] args) {
        final Thread t1 = new Thread(() -> System.out.println("t1"));

        final Thread t2 = new Thread(() -> {
            try {
                // 引用t1线程,等待t1线程执行完
                t1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t2");
        });

        Thread t3 = new Thread(() -> {
            try {
                // 引用t2线程,等待t2线程执行完
                t2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t3");
        });
        //这里三个线程的启动顺序可以任意,但是执行顺序是有序的
        t3.start();
        t2.start();
        t1.start();
    }
}

Condition

image-20220906155523665

Condition工具说明:

从整体上来看Object的wait和notify/notify是与对象监视器配合完成线程间的等待/通知机制,而Condition与Lock配合完成等待通知机制,前者是Java底层级别的,后者是语言级别的,具有更高的可控制性和扩展性。两者除了在使用方式上不同外,在功能特性上还是有很多的不同:
1)Condition能够支持不响应中断,而通过使用Object方式不支持。
2)Condition能够支持多个等待队列(new多个Condition对象),而Object方式只能支持一个。
3)Condition能够支持超时时间的设置,而Object不支持。

原理解析:https://juejin.cn/post/6844903602419400718#heading-3

Condition之单向同步队列

image-20220825134050276

单向同步队列说明:

我们可以多次调用lock.newCondition()方法创建多个condition对象,也就是一个lock可以持有多个等待队列。而在之前利用Object的方式实际上是指在对象Object对象监视器上只能拥有一个AQS同步队列和一个等待队列,而并发包中的Lock拥有一个AQS同步队列和多个等待队列。ConditionObject是AQS的内部类,因此每个ConditionObject能够访问到AQS提供的方法,相当于每个Condition都拥有所属同步器的引用。

Condition之await源码解析

当前线程调用condition.await()方法后,会使得当前线程释放lock然后加入到等待队列中,直至被signal/signalAll后会使得当前线程从等待队列中移至到AQS同步队列中去,直到获得了lock后才会从await方法返回,或者在等待时被中断会做中断处理。
public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
	// 1. 将当前线程包装成Node,尾插入到等待队列中
    Node node = addConditionWaiter();
	// 2. 释放当前线程所占用的lock,在释放的过程中会唤醒AQS同步队列中的下一个节点
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
		// 3. 当前线程进入到等待状态
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
	// 4. 自旋等待获取到同步状态(即获取到lock)
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
	// 5. 处理被中断的情况
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

当前线程添加到等待队列:将当前节点包装成Node,如果等待队列的firstWaiter为null的话(等待队列为空队列),则将firstWaiter指向当前的Node,否则,更新lastWaiter尾节点即可。通过尾插入的方式将当前线程封装的Node插入到等待队列中即可,同时可以看出等待队列是一个不带头结点的链式队列,而AQS同步队列是一个带头结点的链式队列,这是两者的一个区别。将当前节点插入到等待对列之后,会使当前线程释放lock,由fullyRelease方法实现。
private Node addConditionWaiter() {
    Node t = lastWaiter;
    // If lastWaiter is cancelled, clean out.
    if (t != null && t.waitStatus != Node.CONDITION) {
        unlinkCancelledWaiters();
        t = lastWaiter;
    }
	//将当前线程包装成Node
    Node node = new Node(Thread.currentThread(), Node.CONDITION);
    if (t == null)
        firstWaiter = node;
    else
		//尾插入
        t.nextWaiter = node;
	//更新lastWaiter
    lastWaiter = node;
    return node;
}

释放锁:调用AQS的模板方法release方法释放AQS的同步状态并且唤醒在AQS同步队列中头结点的后继节点引用的线程,如果释放成功则正常返回,若失败的话就抛出异常。
final int fullyRelease(Node node) {
    boolean failed = true;
    try {
        int savedState = getState();
        if (release(savedState)) {
			//成功释放同步状态
            failed = false;
            return savedState;
        } else {
			//不成功释放同步状态抛出异常
            throw new IllegalMonitorStateException();
        }
    } finally {
        if (failed)
            node.waitStatus = Node.CANCELLED;
    }
}

退出await方法:当前线程被中断或者调用condition.signal/condition.signalAll方法将当前节点移动到了AQS同步队列后,这是当前线程退出await方法的前提条件。

Condition之signal源码解析

调用condition的signal或者signalAll方法可以将等待队列中等待时间最长的节点移动到AQS同步队列中,使得该节点能够有机会获得lock。按照等待队列是先进先出(FIFO)的,所以等待队列的头节点必然会是等待时间最长的节点,也就是每次调用condition的signal方法是将头节点移动到AQS同步队列中。
public final void signal() {
    //1. 先检测当前线程是否已经获取lock
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    //2. 获取等待队列中第一个节点,之后的操作都是针对这个节点
	Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

private void doSignal(Node first) {
    do {
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
		//1. 将头结点从等待队列中移除
        first.nextWaiter = null;
		//2. while中transferForSignal方法对头结点做真正的处理
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
    /*
     * If cannot change waitStatus, the node has been cancelled.
     */
	//1. 更新状态为0
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;

    /*
     * Splice onto queue and try to set waitStatus of predecessor to
     * indicate that thread is (probably) waiting. If cancelled or
     * attempt to set waitStatus fails, wake up to resync (in which
     * case the waitStatus can be transiently and harmlessly wrong).
     */
	//2.将该节点移入到同步队列中去
    Node p = enq(node);
    int ws = p.waitStatus;
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

调用condition的signal的前提条件是当前线程已经获取了lock,该方法会使得等待队列中的头节点即等待时间最长的那个节点移入到AQS同步队列,而移入到AQS同步队列后才有机会使得等待线程被唤醒,即从await方法中的LockSupport.park(this)方法中返回,从而才有机会使得调用await方法的线程成功退出。

线程锁

线程锁图示

image

线程锁原理解析

JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。锁的状态总共有四种:无锁状态(锁标志位001)、偏向锁(锁标志位01)、轻量级锁(锁标志位00)和重量级锁(锁标志位10)。相关锁底层原理解析:
1)锁升级(锁膨胀)
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。synchronized锁升级原理:在锁对象的对象头里面有一个threadid字段,在第一次访问的时候threadid为空,JVM让其持有偏向锁,并将threadid设置为其线程id,再次进入的时候会先判断threadid是否与其线程id一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了synchronized锁的升级。

2)锁消除
锁消除就是虚拟机根据一个对象是否真正存在同步情况,若不存在同步情况,则对该对象的访问无需经过加锁解锁的操作。比如说使用StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁的竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面代码进行优化,也就是锁消除。即在同一个方法或线程下进行append并返回结果不需要加锁。

3)锁粗化
当频繁的对某个对象进行加锁、解锁,会造成性能上的损失。如果虚拟机探测到有一串零碎操作都是对同一对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。即扩大加锁范围或减少加锁次数。

4)偏向锁(CAS乐观锁)
在只有一个线程执行同步块时进一步提高性能。Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

5)轻量级锁(自旋锁)
在线程交替执行同步块时提高性能。“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

6)重量级锁(Mutex Lock)
在线程同一时间访问同一锁时进行阻塞。Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

7)自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋,类似加载),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态,变重量级锁。

8)可重入锁
也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。如A线程调用加锁方法B,B方法里面再调用加锁方法C,B、C使用同一个锁对象。在JAVA环境下ReentrantLock和synchronized都是可重入锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

9)乐观锁
它是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

10)悲观锁
它是一种悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如ReentrantLock。

11)公平锁
加锁前检查是否有排队等待的线程,优先排队等待的线程获取锁,先来先得。

12)非公平锁
加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待。非公平锁性能比公平锁高5~10倍,因为公平锁需要在多核的情况下维护一个队列。Java中的synchronized是非公平锁,ReentrantLock默认的lock()方法采用的也是非公平锁。

13)独占锁
独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁。独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

14)共享锁
共享锁则允许多个线程同时获取锁,并发访问共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。AQS的内部类Node定义了两个常量SHARED(共享)和EXCLUSIVE(独占),他们分别标识AQS队列中等待线程的锁获取模式。Java的并发包中提供了ReadWriteLock,读-写锁。它允许一个资源可以被多个读操作访问或者被一个写操作访问,但两者不能同时进行。

线程池

创建线程池方式

/**
 * @author XDZY
 * @date 2020/01/14 18:55
 * @description 线程池的使用(jdk1.5之后提供)
 * 使用Executors(/ɪɡˈzekjətər/)线程池工厂类里提供的方法初始化一个线程池ThreadPoolExecutor
 */
public class Demo11 {
    public static void main(String[] args) {
        //创建固定大小的线程池
        //newFixedThreadPool();

        //创建可配置延迟执行的线程池
        newScheduledThreadPool();
    }

    /**
     * 方式一:创建固定大小的线程池
     * 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待,定长线程池的大小最好根据系统资源进行设置。
     * 线程数量是固定的,但是阻塞队列是无界队列。如果有很多请求积压,阻塞队列越来越长,容易导致OOM(超出内存空间)。
     * 请求的挤压一定要和分配的线程池大小匹配,定线程池的大小最好根据系统资源进行设置。
     * 如通过Runtime.getRuntime().availableProcessors()查看电脑CPU核心数量进行合理设置。
     */
    private static void newFixedThreadPool() {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        //调用ExecutorService中的submit方法,传递线程任务(实现类),开启线程,执行run方法
        //pool-1-thread-1创建了一个新的线程
        executorService.submit(() -> System.out.println(Thread.currentThread().getName() + "创建了一个新的线程"));
        //pool-1-thread-2创建了一个新的线程
        executorService.submit(() -> System.out.println(Thread.currentThread().getName() + "创建了一个新的线程"));
        //线程池中2个线程,当有3个任务时,则等待有空闲线程再执行,说明线程池中的线程用完会归还
        //pool-1-thread-2创建了一个新的线程
        executorService.submit(() -> System.out.println(Thread.currentThread().getName() + "创建了一个新的线程"));

        //销毁线程池
        executorService.shutdown();

        //销毁之后提交会报RejectedExecutionException异常
        //executorService.submit(() -> System.out.println(Thread.currentThread().getName() + "创建了一个新的线程"));
    }

    /**
     * 方式二:创建缓存大小的线程池,可变连接池的特点,有几个线程就创建几个
     * 他虽然可以无线的新建线程,但是容易造成堆外内存溢出,因为它的最大值是在初始化的时候设置为Integer.MAX_VALUE,
     * 一般来说机器都没那么大内存给它不断使用。当然知道可能出问题的点,就可以去重写一个方法限制一下这个最大值。
     * 线程池为无限大,当执行第二个任务时第一个任务已经完成,会复用执行第一个任务的线程,而不用每次新建线程。
     */
    private static void newCachedThreadPool() {
        ExecutorService executorService = Executors.newCachedThreadPool();
    }

    /**
     * 方式三:创建单一的线程池,线程池始终都会使用单个线程来处理
     * 在java的多线程中,一但线程关闭,就会成为死线程。关闭后死线程就没有办法在启动了。
     * 再次启动就会出现异常信息:Exception in thread "main" java.lang.IllegalThreadStateException,可以通过这种方式重新启动一个线程。
     * 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它,
     * 他必须保证前一项任务执行完毕才能执行后一项,保证所有任务按照指定顺序(FIFO、LIFO、优先级)执行。
     * 缺点的话,很明显,他是单线程的,高并发业务下有点无力。
     */
    private static void newSingleThreadExecutor() {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
    }

    /**
     * 方式四:创建可配置延迟执行的线程池
     * 创建一个固定长度的线程池,而且支持定时的以及周期性的任务执行,类似于Timer(Timer是Java的一个定时器类)
     * 由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,
     * 前一个任务的延迟或异常都将会影响到之后的任务(比如:一个任务出错,以后的任务都无法继续)。
     */
    private static void newScheduledThreadPool() {
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(3);

        for (int i = 0; i < 10; i++) {
            int finalI = i;
            //延迟10s执行
            executorService.schedule(() -> System.out.println(Thread.currentThread().getName() + ":" + finalI), 10, TimeUnit.SECONDS);
        }
    }
}

线程池参数详解

/**
 * @author XDZY
 * @date 2020/01/14 19:42
 * @description 手动创建线程池
 * 【强制】线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,
 * 这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
 * 说明:Executors各个方法的弊端:
 * 1)newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
 * 2)newCachedThreadPool和newScheduledThreadPool:主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
 * <p>
 * 【强制】创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。
 */
public class Demo12 {
    public static void main(String[] args) {
        /**
         * 创建通用线程池,参数含义:
         * 1)corePoolSize:线程池中常驻的线程数量。
         * 核心线程数,默认情况下核心线程会一直存活,即使处于闲置状态也不会受存keepAliveTime限制。
         * 除非将allowCoreThreadTimeOut设置为true。
         * 2)maximumPoolSize:线程池所能容纳的最大线程数。
         * 超过这个数的线程将被阻塞。当任务队列为没有设置大小的LinkedBlockingDeque时,这个值无效。
         * 3)keepAliveTime:当线程数量多于 corePoolSize 时,空闲线程的存活时长,超过这个时间就会被回收
         * 4)unit:keepAliveTime 的时间单位
         * 5)workQueue:存放待处理任务的队列,该队列只接收 Runnable 接口,被提交但尚未执行的任务
         * 6)threadFactory:线程创建工厂,一般使用默认即可
         * 7)handler:拒绝策略,当任务太多来不及处理,如何拒绝任务
         *
         * 拒绝策略:
         * 1、AbortPolicy:直接抛出异常,阻止系统正常运行。
         * 2、CallerRunsPolicy:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。
         * 显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
         * 3、DiscardOldestPolicy:丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
         * 4、DiscardPolicy:该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。
         * 以上内置拒绝策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足实际需要,
         * 完全可以自己扩展RejectedExecutionHandler接口。
         */
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 200, 0L,
                TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(1024),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());

        for (int i = 0; i < 10; i++) {
            threadPoolExecutor.execute(() ->
                    System.out.println(Thread.currentThread().getName()));
        }

        /**
         * execute和submit都可以开启线程执行池中的任务。
         * execute()只能执行Runnable类型的任务,submit()可以执行Runnable和Callable类型的任务。
         * submit()方法可以返回持有计算结果的Future对象,而execute()没有。
         * submit()方便Exception处理。
         */
        threadPoolExecutor.submit(() -> System.out.println(Thread.currentThread().getName() + "创建了一个新的线程"));

        threadPoolExecutor.shutdown();
    }
}

线程池工作原理

image-20220818161519243

工作原理解析:

1、如果当前运行的线程少于核心线程数(corePoolSize),则创建新线程来执行任务,执行这一步骤需要获取全局锁。
2、如果运行的线程等于或多于corePoolSize,则将任务加入等待队列(BlockingQueue)。
3、如果无法将任务加入BlockingQueue,即队列已满,则新建非核心线程来处理任务,执行这一步骤需要获取全局锁。
4、如果创建新线程将使当前运行的线程超出最大线程数(maximumPoolSize),任务将被拒绝,并调用设置的拒绝策略进行处理。

线程池大小设置

要合理的分配线程池的大小要根据实际情况来定,简单的来说的话就是根据CPU密集和IO密集来分配。CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核CPU上,无论你开几个模拟的多线程,该任务都不可能得到加速,因为CPU总的运算能力就那样。IO密集型,即该任务需要大量的IO,即大量的阻塞。在单线程上运行IO密集型的任务会导致大量的CPU运算能力浪费在等待上。所以在IO密集型任务中使用多线程可以大大的加速程序运行,这种加速主要就是利用了被浪费掉的阻塞时间。所以线程等待时间比CPU执行时间比例越高,需要越多线程,线程CPU执行时间比等待时间比例越高,需要越少线程。

CPU密集型线程数量设置:
对于CPU密集型来说,理论上"线程数量 = CPU 核数(逻辑)"就可以了,但是实际上,数量一般会设置为"CPU 核数(逻辑)+ 1",《Java并发编程实战》这么说:计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。

IO密集型线程数量设置:
最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时)),假如几乎全是I/O耗时,CPU利用率可以说是2N(N=CPU核数),当然也有说2N + 1的。

并发工具

Atomic

package com.study.thread.threadConcurrent;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.junit.Test;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.atomic.AtomicReference;

/**
 * @author XDZY
 * @date 2022/09/20 11:38
 * @description java.util.concurrent.atomic包下的原子类
 * 该包下有AtomicInteger、AtomicBoolean、AtomicLong、AtomicLongArray、AtomicReference等原子类,主要用于在高并发环境下,保证线程安全。
 */
public class AtomicDemo {
    /**
     * AtomicInteger原子更新基本类型
     */
    @Test
    public void atomicDemo() throws Exception {
        final AtomicInteger atomicInteger = new AtomicInteger();

        Thread thread = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                //增加指定数量
                //atomicInteger.getAndAdd(-90);
                //增加1
                //atomicInteger.getAndIncrement();
                //减少1
                atomicInteger.getAndDecrement();
            }
        });

        thread.start();
        thread.join();
        System.out.println("AtomicInteger操作结果:" + atomicInteger.get());
        /**
         * AtomicInteger.incrementAndGet()方法,该方法内有一个死循环,它首先会获取当前值,
         * 然后调用compareAndSet方法,判断当前值是否已经被其他线程修改,如果compareAndSet返回false会继续重试,
         * 直到成功为止,这也就是AtomicInteger能够实现原子性的精髓。
         */
        System.out.println("AtomicInteger操作结果:" + atomicInteger.incrementAndGet());
    }

    /**
     * AtomicIntegerArray原子更新数组类型
     */
    @Test
    public void atomicDemo2() {
        int[] value = new int[]{1, 2, 3};
        AtomicIntegerArray integerArray = new AtomicIntegerArray(value);

        //对数组中索引为1的位置的元素加5
        int result = integerArray.getAndAdd(1, 5);
        System.out.println(integerArray.get(1));//7
        System.out.println(result);//2
    }

    /**
     * AtomicReference原子更新引用类型
     */
    @Test
    public void atomicDemo3() {
        final AtomicReference<AtomicDemo.User> atomicReference = new AtomicReference<>();

        User user1 = new User("a", 1);
        User user2 = new User("b", 2);

        atomicReference.set(user1);
        /**
         * 首先将对象User1用AtomicReference进行封装,然后调用getAndSet方法,从结果可以看出,
         * 该方法会原子更新引用的user对象,变为User{userName='b', age=2},
         * 返回的是原来的user对象User{userName='a', age=1}。
         */
        User oldUser = atomicReference.getAndSet(user2);

        System.out.println(oldUser);
        System.out.println(atomicReference.get());
    }

    /**
     * AtomicIntegerFieldUpdater原子更新字段类型
     */
    @Test
    public void atomicDemo4() {
        AtomicIntegerFieldUpdater<User> integerFieldUpdater = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
        User user = new User("a", 1);
        int oldValue = integerFieldUpdater.getAndAdd(user, 5);
        System.out.println(oldValue);//1
        System.out.println(integerFieldUpdater.get(user));//6
    }

    @Data
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public static class User {
        private String userName;
        //原子更新字段时设置为public volatile
        public volatile int age;
    }
}

CountDownLatch

CountDownLatch计数器最主要的作用是允许一个或多个线程等待其他线程完成操作。比如我们现在有一个任务,有n个线程会往数组data[N]当中对应的位置根据不同的任务放入数据,在各个线程将数据放入之后,主线程需要将这个数组当中所有的数据进行求和计算,也就是说主线程在各个线程放入之前需要阻塞住。在这样的场景下,我们就可以使用CountDownLatch。

/**
 * 主线程通过调用latch.await();将自己阻塞住,然后需要等待其他线程调用方法latch.countDown(),只有这个方法被调用的次数等于在初始化时给CountDownLatch传递的参数时,主线程才会被释放。
 */
@Test
public void cdlTest() throws InterruptedException {
    int[] data = new int[10];
    CountDownLatch latch = new CountDownLatch(10);

    for (int i = 0; i < 10; i++) {
        int temp = i;
        new Thread(() -> {
            Random random = new Random();
            data[temp] = random.nextInt(100001);
            latch.countDown();
        }).start();
    }

    // 只有函数 latch.countDown() 至少被调用10次
    // 主线程才不会被阻塞
    // 这个10是在CountDownLatch初始化传递的10
    latch.await();
    System.out.println("求和结果为:" + Arrays.stream(data).sum());
}

CyclicBarrier

CyclicBarrier(/ˈsaɪklɪk/ /ˈbæriər/)循环屏障工具要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。我们通常也将CyclicBarrier称作路障。

/**
 * 循环屏障测试
 */
@Test
public void cyclicBarrierTest() {
    // 我们在初始化CyclicBarrier对象时,传递的数字为5,这个数字表示只有5个线程到达同步点的时候,那5个线程才会同时被放行,
    // 而如果到了6个线程的话,第一次没有被放行的线程必须等到下一次有5个线程到达同步点barrier.await()时,才会放行5个线程。
    // 当有5个线程或者更多的线程到达同步点barrier.await()的时候,才会放行5个线程,注意是5个线程,
    // 如果有多的线程必须等到下一次集合5个线程才会进行又一次放行,也就是说每次只放行5个线程,
    // 这也是它叫做CyclicBarrier(循环路障)的原因(因为每次放行5个线程,放行完之后重新计数,
    // 直到又有5个新的线程到来,才再次放行)。
    CyclicBarrier barrier = new CyclicBarrier(5);

    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + "开始等待");
                // 所有线程都会调用这行代码,在这行代码调用的线程个数不足5个的时候所有的线程都会阻塞在这里,
                // 只有到5的时候,这5个线程才会被放行,所以这行代码叫做同步点。
                barrier.await();
                // 如果有第六个线程执行这行代码时,第六个线程也会被阻塞,
                // 直到第10个线程执行这行代码,6-10这5个线程才会被放行。
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "等待完成");
        }).start();
    }
}

Semaphore

Semaphore(/ˈseməfɔːr/)限流工具(信号量)通俗一点的来说就是控制能执行某一段代码的线程数量,他可以控制程序的并发量。

/**
 * 每次只能5个进入
 */
@Test
public void semaphoreTest2() {
    Semaphore mySemaphore = new Semaphore(5);
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "准备进入临界区");
            try {
                mySemaphore.acquire();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "已经进入临界区");
            //                try {
            //                    TimeUnit.SECONDS.sleep(2);
            //                } catch (InterruptedException e) {
            //                    e.printStackTrace();
            //                }
            System.out.println(Thread.currentThread().getName() + "准备离开临界区");
            mySemaphore.release();
            System.out.println(Thread.currentThread().getName() + "已经离开临界区");
        }).start();
    }
}

并发容器

同步容器与并发容器

同步容器:可以简单地理解为通过synchronized来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。比如Vector、Hashtable,以及Collections.synchronizedSet,synchronizedList等方法返回的容器。可以通过查看Vector、Hashtable等这些同步容器的实现代码,可以看到这些容器实现线程安全的方式就是将它们的状态封装起来,并在需要同步的方法上加上关键字synchronized。

并发容器:使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性,例如在ConcurrentHashMap中采用了一种粒度更细的加锁机制,可以称为分段锁,在这种锁机制下,允许任意数量的读线程并发地访问map,并且执行读操作的线程和写操作的线程也可以并发的访问map,同时允许一定数量的写操作线程并发地修改map,所以它可以在并发环境下实现更高的吞吐量。

JDK7中ConcurrentHashMap

1)ConcurrentHashMap保证线程安全
ConcurrentHashMap是Java5中支持高并发、高吞吐量的线程安全HashMap实现。它由Segment分段(/*ˈseɡmənt*/)数组结构和HashEntry数组结构组成。Segment数组在ConcurrentHashMap里扮演锁的角色(分段锁),HashEntry则用于存储键-值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构;一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素;每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,只要多个修改操作发生在不同的段上,它们就可以并发进行。

2)保证获取的元素最新
用于存储键值对数据的HashEntry,在设计上它的成员变量value跟next都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。get方法和containsKey方法都是遍历对应索引位上所有节点,都是不加锁来判断的,如果是修改性质的因为可见性的存在可以直接获得最新值,不过如果是新添加值则无法保持一致性。比如迭代器在遍历数据的时候是一个Segment一个Segment去遍历的,如果在遍历完一个Segment时正好有一个线程在刚遍历完的Segment上插入数据,就会体现出不一致性,clear也是一样。

3)扩容机制
段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测是否需要扩容,有效避免无效扩容。

JDK8中ConcurrentHashMap

1)与JDK7中ConcurrentHashMap的不同点
JDK8中ConcurrentHashMap取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率,并发控制使用Synchronized和CAS来操作。存储数据时采用了数组+链表+红黑树的形式。

2)初始化操作
initTable:只允许一个线程对表进行初始化,如果不巧有其他线程进来了,那么会让其他线程交出CPU等待下次系统调度Thread.yield。这样,保证了表同时只会被一个线程初始化,对于table的大小,会根据sizeCtl的值进行设置,如果没有设置szieCtl的值,那么默认生成的table大小为16,否则,会根据sizeCtl的大小设置table大小。

3)读写操作
读操作:get方法中根本没有使用同步机制,也没有使用unsafe方法,所以读操作是支持并发操作的。
写操作:假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作。做一些边界处理,然后获得hash值,没初始化就初始化,初始化后看下对应的桶是否为空,为空就原子性的尝试插入,如果当前节点正在扩容还要去帮忙扩容。用synchronized来加锁当前节点,然后操作几乎跟就跟HashMap一样了。

4)保证获取的元素最新
unsafe方法:在java内存模型中,我们已经知道每个线程都有一个工作内存,里面存储着table的副本,虽然table是volatile修饰的,但不能保证线程每次都拿到table中的最新元素,Unsafe.getObjectVolatile可以直接获取指定内存的数据,保证了每次拿到数据都是最新的。

5)扩容机制
在往map中添加元素的时候,在某一个节点的数目已经超过了8个,同时数组的长度又小于64的时候,才会触发数组的扩容。当数组中元素达到了sizeCtl的数量的时候,则会调用transfer方法来进行扩容。

transfer:单个线程能处理的最少桶结点个数的计算和一些属性的初始化操作。每个线程进来会先领取自己的任务区间[bound,i],然后开始--i来遍历自己的任务区间,对每个桶进行处理。如果遇到桶的头结点是空的,那么使用ForwardingNode标识旧table中该桶已经被处理完成了。如果遇到已经处理完成的桶,直接跳过进行下一个桶的处理。如果是正常的桶,对桶首节点加锁,正常的迁移即可,迁移结束后依然会将原表的该位置标识为已经处理。该函数中的finish=true则说明整张表的迁移操作已经全部完成了,我们只需要重置table的引用并将nextTable赋为空即可。否则,CAS式的将sizeCtl减一,表示当前线程已经完成了任务,退出扩容操作。当前线程如果退出成功,那么需要进一步判断当前线程是否是最后一个在执行扩容的,只有最后一个任务执行完成,才会退出方法。

资料文档:https://juejin.cn/post/6844904136937308168#heading-1

并发队列

非阻塞、阻塞队列分类

非阻塞队列:
1)ArrayDeque(数组双端队列):ArrayDeque是JDK容器中的一个双端队列实现,内部使用数组进行元素存储,不允许存储null值,可以高效的进行元素查找和尾部插入取出,是用作队列、双端队列、栈的绝佳选择,性能比LinkedList还要好。
2)PriorityQueue(优先级队列):PriorityQueue一个基于优先级的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的Comparator进行排序,具体取决于所使用的构造方法。该队列不允许使用null元素也不允许插入不可比较的对象。
3)ConcurrentLinkedQueue(基于链表的并发队列):ConcurrentLinkedQueue是一个适用于高并发场景下的队列,通过无锁的方式,实现了高并发状态下的高性能。ConcurrentLinkedQueue的性能要好于BlockingQueue接口,它是一个基于链接节点的无界线程安全队列。该队列的元素遵循先进先出的原则。该队列不允许null元素。

阻塞队列与我们平常接触的普通队列(LinkedList或ArrayList等)的最大不同点,在于阻塞队列支持阻塞添加和阻塞删除方法。阻塞添加:所谓的阻塞添加是指当阻塞队列元素已满时,队列会阻塞加入元素的线程,直到队列元素不满时才重新唤醒线程执行元素加入操作。阻塞删除:阻塞删除是指在队列元素为空时,删除队列元素的线程将被阻塞,直到队列不为空再执行删除操作(一般都会返回被删除的元素)。

阻塞队列:
1)DelayQueue(基于时间优先级的队列,延期阻塞队列):DelayQueue是一个没有边界BlockingQueue实现,加入其中的元素必需实现Delayed接口。当生产者线程调用put之类的方法加入元素时,会触发Delayed接口中的compareTo方法进行排序,也就是说队列中元素的顺序是按到期时间排序的,而非它们进入队列的顺序。排在队列头部的元素是最早到期的,越往后到期时间赿晚。
2)ArrayBlockingQueue(基于数组的并发阻塞队列):ArrayBlockingQueue是一个有边界的阻塞队列,它的内部实现是一个数组。有边界的意思是它的容量是有限的,我们必须在其初始化的时候指定它的容量大小,容量大小一旦指定就不可改变。ArrayBlockingQueue是以先进先出的方式存储数据。
3)LinkedBlockingQueue(基于链表的FIFO阻塞队列):LinkedBlockingQueue阻塞队列大小的配置是可选的,如果我们初始化时指定一个大小,它就是有边界的,如果不指定,它就是无边界的。说是无边界,其实是采用了默认大小为Integer.MAX_VALUE的容量 。它的内部实现是一个链表。
4)LinkedBlockingDeque(基于链表的FIFO双端阻塞队列):LinkedBlockingDeque是一个由链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。相比于其他阻塞队列,LinkedBlockingDeque多了addFirst、addLast、peekFirst、peekLast等方法,以first结尾的方法,表示插入、获取和移除双端队列的第一个元素。以last结尾的方法,表示插入、获取和移除双端队列的最后一个元素。LinkedBlockingDeque是可选容量的,在初始化时可以设置容量防止其过度膨胀,如果不设置,默认容量大小为Integer.MAX_VALUE。
5)PriorityBlockingQueue(带优先级的无界阻塞队列):PriorityBlockingQueue是一个无界队列,它没有限制,在内存允许的情况下可以无限添加元素;它又是具有优先级的队列,是通过构造函数传入的对象来判断,传入的对象必须实现comparable接口。
6)SynchronousQueue(并发同步阻塞队列):SynchronousQueue是一个内部只能包含一个元素的队列。插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。将这个类称为队列有点夸大其词,这更像是一个点。

并发队列常用方法

public interface BlockingQueue<E> extends Queue<E> {
    //将指定的元素插入到此队列的尾部(如果立即可行且不会超过该队列的容量),
    //在成功时返回 true,如果此队列已满,则抛出 IllegalStateException。
    boolean add(E e);

    //将指定的元素插入到此队列的尾部(如果立即可行且不会超过该队列的容量),
    //在成功时返回 true,如果此队列已满,则返回 false。
    boolean offer(E e);

    // 将指定的元素插入此队列的尾部,如果该队列已满,
    //则在到达指定的等待时间之前等待可用的空间,该方法可中断
    boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;

    //将指定的元素插入此队列的尾部,如果该队列已满,则一直等到(阻塞)。
    void put(E e) throws InterruptedException;

    //获取并移除此队列的头部,如果没有元素则等待(阻塞),
    //直到有元素将唤醒等待线程执行该操作
    E take() throws InterruptedException;

    //获取并移除此队列的头部,在指定的等待时间前一直等到获取元素,
    //超过时间方法将结束
    E poll(long timeout, TimeUnit unit)
        throws InterruptedException;

    //从此队列中移除指定元素的单个实例(如果存在)。
     boolean remove(Object o);
}

//除了上述方法还有继承自Queue接口的方法
//获取但不移除此队列的头元素,没有则跑异常NoSuchElementException
E element();

//获取但不移除此队列的头;如果此队列为空,则返回 null。
E peek();

//获取并移除此队列的头,如果此队列为空,则返回 null。
E poll();

阻塞队列接口主要方法:

方法名 描述
add() 在不超出队列长度的情况下插入元素,可以立即执行,成功返回true,如果队列满了就抛出异常。
offer() 在不超出队列长度的情况下插入元素的时候则可以立即在队列的尾部插入指定元素,成功时返回true,如果此队列已满,则返回false。
put() 插入元素的时候,如果队列满了就进行等待,直到队列可用。
take() 从队列中获取值,如果队列中没有值,线程会一直阻塞,直到队列中有值,并且该方法取得了该值。
poll(long timeout, TimeUnit unit) 在给定的时间里,从队列中获取值,如果没有取到会抛出异常。
remainingCapacity() 获取队列中剩余的空间。
remove(Object o) 从队列中移除指定的值。
contains(Object o) 判断队列中是否拥有该值。
drainTo(Collection c) 将队列中值,全部移除,并发设置到给定的集合中。

ArrayBlockingQueue

ArrayBlockingQueue的内部是通过一个可重入锁ReentrantLock和两个Condition条件对象来实现阻塞。

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    /** 存储数据的数组 */
    final Object[] items;

    /**获取数据的索引,主要用于take,poll,peek,remove方法 */
    int takeIndex;

    /**添加数据的索引,主要用于 put, offer, or add 方法*/
    int putIndex;

    /** 队列元素的个数 */
    int count;


    /** 控制并非访问的锁 */
    final ReentrantLock lock;

    /**notEmpty条件对象,用于通知take方法队列已有元素,可执行获取操作 */
    private final Condition notEmpty;

    /**notFull条件对象,用于通知put方法队列未满,可执行添加操作 */
    private final Condition notFull;

    /**
       迭代器
     */
    transient Itrs itrs = null;
}

put实现原理分析:put方法是一个阻塞的方法,如果队列元素已满,那么当前线程将会被notFull条件对象挂起加到等待队列中,直到队列有空档才会唤醒执行添加操作。但如果队列没有满,那么就直接调用enqueue(e)方法将元素加入到数组队列中。说白了就是当队列满时通过条件对象Condtion来阻塞当前调用put方法的线程,直到线程又再次被唤醒执行。总得来说添加线程的执行存在以下两种情况,一是,队列已满,那么新到来的put线程将添加到notFull的条件队列中等待,二是,有移除线程执行移除操作,移除成功同时唤醒put线程。
    
remove实现原理分析:remove(Object o)方法的删除过程相对复杂些,因为该方法并不是直接从队列头部删除元素。首先线程先获取锁,再一步判断队列count>0,这点是保证并发情况下删除操作安全执行。接着获取下一个要添加源的索引putIndex以及takeIndex索引 ,作为后续循环的结束判断,因为只要putIndex与takeIndex不相等就说明队列没有结束。然后通过while循环找到要删除的元素索引,执行removeAt(i)方法删除,在removeAt(i)方法中实际上做了两件事,一是首先判断队列头部元素是否为删除元素,如果是直接删除,并唤醒添加线程,二是如果要删除的元素并不是队列头元素,那么执行循环操作,从要删除元素的索引removeIndex之后的元素都往前移动一个位置,那么要删除的元素就被removeIndex之后的元素替换,从而也就完成了删除操作。

LinkedBlockingQueue

LinkedBlockingQueue是一个由链表实现的有界队列阻塞队列,但大小默认值为Integer.MAX_VALUE,所以我们在使用LinkedBlockingQueue时建议手动传值,为其提供我们所需的大小,避免队列过大造成机器负载或者内存爆满等情况。在正常情况下,链接队列的吞吐量要高于基于数组的队列ArrayBlockingQueue,因为其内部实现添加和删除操作使用的两个ReenterLock来控制并发执行,而ArrayBlockingQueue内部只是使用一个ReenterLock控制并发。每个添加到LinkedBlockingQueue队列中的数据都将被封装成Node节点,添加的链表队列中,其中head和last分别指向队列的头结点和尾结点。与ArrayBlockingQueue不同的是,LinkedBlockingQueue内部分别使用了takeLock和 putLock对并发进行控制,也就是说,添加和删除操作并不是互斥操作,可以同时进行,这样也就可以大大提高吞吐量。这里再次强调如果没有给LinkedBlockingQueue指定容量大小,其默认值将是Integer.MAX_VALUE,如果存在添加速度大于删除速度时候,有可能会内存溢出,这点在使用前需要慎重考虑。

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    /**
     * 节点类,用于存储数据
     */
    static class Node<E> {
        E item;

        /**
         * One of:
         * - the real successor Node
         * - this Node, meaning the successor is head.next
         * - null, meaning there is no successor (this is the last node)
         */
        Node<E> next;

        Node(E x) { item = x; }
    }

    /** 阻塞队列的大小,默认为Integer.MAX_VALUE */
    private final int capacity;

    /** 当前阻塞队列中的元素个数 */
    private final AtomicInteger count = new AtomicInteger();

    /**
     * 阻塞队列的头结点
     */
    transient Node<E> head;

    /**
     * 阻塞队列的尾节点
     */
    private transient Node<E> last;

    /** 获取并移除元素时使用的锁,如take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** notEmpty条件对象,当队列没有数据时用于挂起执行删除的线程 */
    private final Condition notEmpty = takeLock.newCondition();

    /** 添加元素时使用的锁如 put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** notFull条件对象,当队列数据已满时用于挂起执行添加的线程 */
    private final Condition notFull = putLock.newCondition();
}

与ArrayBlockingQueue不同之处
1)队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。
2)数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。
3)由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
4)两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
posted @ 2022-12-01 16:07  肖德子裕  阅读(108)  评论(0编辑  收藏  举报