java多线程学习复习一篇就够了

全文从多线程的实现方式、线程的状态、线程的方法、线程的同步、线程的通讯、等角度对多线程的基础知识进行总结

一、多线程的实现方式

  • 继承Thread类,重写run方法
  • 实现Runnable接口,重写run方法
  • 实现Callable接口,重写call方法
    这是三种多线程的创建方式,此外我们创建多线程时可以使用多个Thread对象开启同一个Runnable接口实现类达到多线程的目的,也可以使用多个Thread对象开启多个Runnable实现类来达到多线程,这两种实现方法笔者思考了下,感觉都是有应用场景的,我们在购票系统中,投票的方法其实是多线程都需要调用的,我们就可以使用多个Thread开启同一个Runnable实现类的方式来实现多线程,只需要为卖票方法加锁即可。但若是有一个队列我们想要用多线程去处理队列里面的内容,此时就可以使用多个Thread开启多个Runnable实现类的方式来适用不同对象的不同处理方式,从而提高处理效率。

1.继承Thread类,重写run方法

这种实现方式是jdk1.0便已经存在了,实现也很简单,直接继承Thread,重写run方法,然后我们通过Thread对象的start方法启动即可。
示例代码如下:

public class Test1 extends Thread{
    @Override
    public void run() {
        while(true){
            System.out.println("线程1");
        }

    }

    public static void main(String[] args) {
        new Test1().start();
    }
}

2.实现Runnable接口,重写run方法

实现Runnable接口,其实与第一种实现方式本质上还是一样的,因为Thread就是实现了Runnable接口。当使用实现Runnable接口的方式实现多线程时,应该重写run方法。启动线程时使用Thread的start方法,示例代码如下:

public class TestThread {
    public static void main(String[] args) {
        new Thread(() -> {
            while(true)
                System.out.println("Runnable多线程1");
        }).start();
        
        new Thread(() -> {
            while(true)
                System.out.println("Runnable多线程2");
        }).start();
    }
}

3.实现Callable接口,重写call方法

Callable接口是JDK1.5加入的,位置在java并发包里面,他的功能比较强大支持异常的抛出线程执行结果的返回。这些都是前面两种实现方式所不具备的。前面两种能完成的实现Callable都可以完成,前面两种完不成的Callable也可以完成,所以没有理由不使用Callbale。示例代码如下:

public class TestThread {
    public static void main(String[] args) throws Exception{
        FutureTask<String> futureTask = new FutureTask<>(()->{
            int i =0 ;
            while(i<100)
                System.out.println("Callable线程1在执行:"+i++);
            return "线程1执行完了";
        });

        FutureTask<String> futureTask2 = new FutureTask<>(()->{
            int i =0 ;
            while(i<100)
                System.out.println("Callable线程2在执行:"+i++);
            return "线程2执行完了";
        });

        new Thread(futureTask).start();
        new Thread(futureTask2).start();
        System.out.println(futureTask.get());
        System.out.println(futureTask2.get());
    }
}

以上是三种多线程的实现方式,有人会把线程池也算成一种多线程的实现方式,笔者认为线程池并不能算是一种方式,他只是一种池化技术,这种技术在编程中有很多场景,底层还是使用的一样的技术而已。总结以上三种多线程的实现方式,

  • 可以发现Thread是最low的一种,因为他是基于继承来实现的,而java中类时不支持多继承的(接口可以多继承),所以他会影响到线程类的扩展性
  • 若是不需要线程的返回值建议使用Runnable方式,需要的话就只能选择Callable了
  • 所有线程的创建方式都需要依赖Thread类的start方法,说明该方法才是创建线程的真正方法,该方法也是一个native方法。
  • 因为所有线程的启动都需要依赖Thread,所以Thread中的方法可以说都是公用的,Thread也是很重要的

不过当下全是使用线程池来实现多线程技术,基本没有手动去创建线程的,所以线程池的使用和手动实现线程池才是应该注意的地方。

二、线程的状态

在这里插入图片描述

1.new状态

创建出Thread的对象后就是new的状态了。

2.就绪状态

Thread的start方法才是创建一个线程的根本,start方法调用native的实现去开启一个线程(可以发现无论哪种多线程的实现方式都需要Thread的start方法来开启线程),当这个start方法执行后线程就是new的状态了。

3.运行状态

运行状态就是线程在运行run方法或者call方法体中的内容了,线程在运行状态中可以进入到阻塞状态,也可以进入到就绪状态,比如yield就会让线程进入就绪状态,wait、sleep会让线程进入到阻塞状态。

4.阻塞状态

当现场争抢cpu时间片失败后就会进入到阻塞状态,阻塞状态中的线程需要等他其他线程释放cpu资源然后再一次和其他线程去争抢cpu的时间片,争抢成功则进入到运行状态,否则再次进入到阻塞状态。

5.死亡状态:线程如何正确的死亡

我们知道Thread已经提供了stop等方法用于停止一个线程或者说让一个线程死亡,但是stop方法是Dreprected的,是不推荐使用的,那我们该如何正确的停止一个线程呢?答案是在主线程中维护一个boolean变量,用这个变量控制线程的运行和死亡,比如如下代码:我们可以在适当的时候将flag置为false,这样线程也就结束了。此处有一点需要注意,volatile关键字修饰了flag,被volatile关键字修饰的变量一旦改变,会强制子线程中的该变量失效,子线程需要从主线程从新获取(可见性问题)。

public class TestThread {
    volatile static Boolean  flag = true;


    public static void main(String[] args) throws Exception{
        FutureTask<String> futureTask = new FutureTask<>(()->{
            int i =0 ;
            while(flag)
                System.out.println("Callable线程1在执行:"+i++);
            return "线程1执行完了";
        });

        new Thread(futureTask).start();
        System.out.println(futureTask.get());
    }
}

我们都知道

三、Thread中常用的方法

1.setPriority():

设置线程优先级
需要注意的是线程的优先级高也不一定就肯定先执行,只是优先级高的线程先执行的概率更大而已,优先级从1-10,10为最高,不设置默认为5,到底谁先执行与操作系统有关系,主要看操作系统对cpu的调度,示例代码如下:

public class TestThread {
    volatile static  Boolean  flag = true;

    public static void main(String[] args) throws Exception {
        Thread thread1 = new Thread(() -> {
            System.out.println("优先级为1的线程");
        });
        Thread thread2 = new Thread(() -> {
            System.out.println("优先级为3的线程");
        });
        Thread thread3 = new Thread(() -> {
            System.out.println("优先级为5的线程");
        });
        Thread thread4 = new Thread(() -> {
            System.out.println("优先级为7的线程");
        });
        Thread thread5 = new Thread(() -> {
            System.out.println("优先级为10的线程");
        });
        thread1.setPriority(1);
        thread2.setPriority(3);
        thread3.setPriority(5);
        thread4.setPriority(7);
        thread5.setPriority(10);
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

2.sleep():

让线程进入阻塞状态这个方法用过的人应该是非常多了,传入的是一个int型整数,代表毫秒。目的是让线程睡一会。该方法结束后线程进入就绪状态。值得注意的是每个线程都有一把锁,而sleep并不会释放这把锁。

3.join():

这是个插队的方法,他不是静态方法,需要使用Thread的对象来调用,当一个线程调用join方法时,该线程会强制抢占cpu资源,直到该线程执行完毕其他线程才会继续执行,示例代码如下:

public class TestThread {
    volatile static  Boolean  flag = true;

    public static void main(String[] args) throws Exception{
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                System.out.println("vip线程1:"+i);
            }
        });

        for (int i = 0; i < 500; i++) {
            System.out.println("主线程:"+i);
            if(i==200)
                thread.join();
        }

        thread.start();
    }

}

4.yield():

释放cpu资源,让当前线程进入就绪状态,礼让操作,重新和其他线程竞争cpu资源。需要说的是礼让并不一定成功。笔者多次测试都礼让成功了,看来礼让还是有效果的。示例代码如下:

public class TestThread {
    volatile static  Boolean  flag = true;

    public static void main(String[] args) {
        new Thread(() -> {
            int i = 0;
            while(i<100){
                if(i==50)
                    Thread.yield();
                System.out.println("线程1:"+i++);
            }
        }).start();

        new Thread(() -> {
            int i = 0;
            while(i<100)
                System.out.println("线程2:"+i++);
        }).start();
    }
}

5.interrupt():

中断线程有四种放方式,方式一线程正常执行结束终止,方式二使用stop可以终止,方式三使用volatile修饰的标志符控制线程的终止,方式四便可以使用interrupt方法。必须要注意的是单纯使用该方法是不能达到阻断线程的目的的,该方法只是改变线程的阻断标志而已,只是改变了阻断标志,并不会做其他事情,我们可以判断阻断标志的变化来停止线程(默认是false,执行interrupt方法后是true)。代码如下(这里必须使用继承Thread的方式实现多线程):

public class TestThread {
    volatile static  Boolean  flag = true;

    public static void main(String[] args) {
        Thread thread1 = new Thread(){
            @Override
            public void run() {
                while(!isInterrupted()){
                    System.out.println("线程1在运行");
                }
            }
        };

        Thread thread2 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    if(i==50){
                        System.out.println("################### 线程2试图阻断线程1 ###################");
                        thread1.interrupt();
                    }else{
                        System.out.println("线程2在运行:"+i);
                    }
                }
            }
        };

        thread1.start();
        thread2.start();
    }
}

也可以使用interrupt配合sleep方法来一起阻断线程,sleep方法有机会抛出InterruptedException异常,我们捕获该异常并break,也是可以退出线程的。示例代码如下(与上一部分代码相比,只改动了第一个线程的代码):

public class TestThread {
    volatile static  Boolean  flag = true;

    public static void main(String[] args) {
        Thread thread1 = new Thread(){
            @Override
            public void run() {
                while(true){
                    System.out.println("线程1在运行");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        System.out.println("发生了阻断异常。。。。");
                        break;
                    }
                }
            }
        };

        Thread thread2 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    if(i==50){
                        System.out.println("################### 线程2试图阻断线程1 ###################");
                        thread1.interrupt();
                    }else{
                        System.out.println("线程2在运行:"+i);
                    }
                }
            }
        };

        thread1.start();
        thread2.start();
    }
}

做下总结,我们阻断线程的操作都是在其他线程中进行的,在实际的场景中肯定也都是在其他线程阻断,在自己线程中的阻断我们称之为线程的正常运行结束。在其他线程中阻断,然后一种是在线程中判断阻断标志停止线程,另一种就是使用sleep然后捕获InterruptedException异常来break线程的运行。

6.isAlive():

判断当前线程是否是活性的

7.stop、destroy:

这些线程停止方法不推荐使用,stop方法是暴力停止线程,使用该方法后线程会立即停止,但是使用stop可能会导致数据不同步、资源未正常释放等结果。所以是不推荐使用的,此外destroy方法也是不推荐使用的,目前destroy方法体中只有一行代码,直接抛出了一个异常,destroy方法如下:

    @Deprecated
    public void destroy() {
        throw new NoSuchMethodError();
    }

所以总结stop、destroy这两个方法都不适合用于停止线程,真正用于停止线程的操作还是interrupt中讲述的四种停止方法。

8.守护线程 setDaemon

可以通过Thread的setDaemon,将线程设置为守护线程,最典型的守护线程是垃圾收集线程。守护线程的作用就是用来守护用户线程的,虚拟机只会保证用户线程的执行完毕,守护线程会随着虚拟机的关闭而关闭。

四、线程的同步机制

多线程必然伴随着并发修改的风险,多个线程同时修改一个变量就会有线程安全问题,线程安全问题需要从三个方面考虑:

  • 原子性:要求一个操作是不可拆分的,执行过程中不会被其他线程切入。执行要么成功要么失败,不会被其他线程抢占操作,该场景可使用Synchronized、lock、reentrantlock、CAS+失败重试解决
  • 可见性:一个线程对变量进行了修改,要求这个修改对其他线程可见,该场景可使用Volatile关键字解决。
  • 顺序性:比如即时编译器就会对指令进行重排,从而可能会影响到变量的赋值操作,在多现程中可能就会造成数据失真,为了保证有序性可以使用Volatile关键字修饰变量。
    下面就一起来看下多线程中这些常用的操作。

1.Synchronized

  • 作用
    synchronized用来修饰方法或者代码块,被synchronized修饰的方法或者代码块都会变成线程安全的,任何一个时间内都只能有一个线程在操作被synchronized修饰的方法或者代码块。那synchronized是如何加的锁呢?每个对象默认都持有一把锁,这个锁存储在对象的头部信息中,synchronized拿到的就是这把锁,只要有一个线程拿到了这把锁,其他线程都会处于阻塞中进行等待,只有主动释放后其他线程才有机会去获取这把锁。synchronized可以解决原子性、可见性问题,不能解决顺序性问题。
  • synchronized修饰普通方法
    synchronized既可以修饰方法也可以修饰代码块。但是修饰方法和修饰代码块的实现机制是完全不同的,这里展示下一个synchronized修饰方法的代码如下,synchronized修饰方法来实现同步时,他的实现在字节码层面是隐式的,无需字节码的指令来控制,他的实现是通过在方法对应的方法表中维护一个ACC_SYNCHRONIZED标志来判断方法是否是同步的,每当方法被调用时就会去对应的方法表中查看该标志,若是同步的就需要先去持有锁,执行结束再释放锁,若是遇到异常当异常抛出方法体时也会自动释放锁。
        private synchronized static void addT(List list,Integer i){
            list.add(i);
    }
    

    修饰方法时,谁调用这个方法那么持有的就是谁的锁。

  • synchronized修饰静态方法
    synchronized修饰静态方法时相当于给整个类的同步方法加锁(可以写两个同步方法,一个静态一个非静态用同一个对象调用测试,会发现只有一个线程执行结束另一个才会执行)。其实原理是因为修饰静态方法时用的锁是类型的class对象的锁,而每个类都有且只有一个class对象,故而当一个线程获取到以后其他所有线程都只能等待,所以实现了synchrozed修饰静态方法,其他所有同步方法都必须等待的效果。
  • synchronized修饰代码块
    synchronized修饰方法时是通过方法表中的ACC_SYNCHRONIZED标志来判断是否是同步方法的,而修饰代码块则又不同。修饰代码块时是通过字节码指令来控制同步的,下面是一段synchronized修饰的代码块。当编译器遇到synchronized修饰代码块时会在字节码文件中将
    synchronized翻译成两条字节码指令:monitorenter、monitorexit。这两条指令共同控制了代码块的同步实现。前一条指令持有锁,后一条指令释放锁。不过值得说到的是,在锁这个概念中他们应该是保持一致的,无论是修饰方法持有的锁,还是修饰代码块持有的锁都是对象头部中的锁,值得主义的而是synchrozed修饰代码块同样可以传入class对象,此时传入class对象效果等同于修饰静态方法。
        private static void add(List list,Integer i){
        synchronized(TestThread.class){
            list.add(i);
        }
    }
    

    上面的代码中持有的锁是TestThread.class的锁,需要声明的是所有的类在被虚拟机加载时都会同时生成一个他对应的class对象,且这个对象是唯一的,后期无论我们怎么去创建class对象其实都是只有这最初创建的一个。这里持有的就是它的锁。加锁时我们应该保证锁是唯一的,不能让每个线程持有的锁都不一样,这样就会没有任何意义,也达不到同步的效果。

  • synchronized的弊端
    加锁的目的是为了让线程的增删改操作变得安全,不会因为多线程的并发操作而让数据失真,但是鱼和熊掌不可兼得,保证了安全就会失去性能。加锁无疑会让程序的执行变得更慢,synchronized的安全性很高,同时它的效率就很低,因此后来也就相继出现了ReentrantLock、CAS等技术。来降低锁的重量。

2.Lock

  • 显示锁与隐式锁
    synchronized是一种重量级的锁,同时它也是一种隐式的锁,何为隐式呢,隐式是指的是加锁和解锁的过程是隐式的。如果这句话不是这么清晰那么看下显示锁Lock就会很好理解。Lock的加锁和解锁很简单只需要在代码中显示的声明lock和unlock就行,这就是显示锁。JDK1.5时引入并发包,Lock便是并发包中的一个接口,ReentrantLock就是该接口的实现类。
  • 使用举例
    下面列举了可重入锁的使用,使用时注意应该将锁的释放放在finally代码块里,或者不过Lock并不是只有一个实现,除了可重入锁还有ReadLock、ReadLockView、WriteLock、WriteLockView等锁,与synchronized相比,Lock更灵活,性能也是更高的,
    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
        static final Lock lock = new ReentrantLock();
        static List list = new ArrayList();
        public static void main(String[] args) throws InterruptedException{
            new Thread( () -> {
                add(list);
            }).start();
            new Thread( () -> {
                add(list);
            }).start();
            Thread.sleep(1000);
            System.out.println(list.size());
        }
    
        public static  void add(List list){
            for (int i = 0; i < 1000; i++) {
                lock.lock();
                try {
                    list.add(i);
                } catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    lock.unlock();
                }
            }
        }
    
    }
    
    
  • 对比synchronized与Lock
    无论是synchronized还是Lock都可以保证多线程中的增删改的线程安全,都可以解决原子性和可见性问题,那既然他们功能一样总要分个性能高低吧,就像我们使用多种方式实现多线程一样,总有一种应该是相对更好的,下面就来对比下这两种锁的区别。
    ①Lock是显示锁,需要手动释放,synchronized是隐式锁,锁的释放是在退出作用域后自动释放。
    ②Lock只能锁代码块,synchronized可以锁代码块和方法。
    ③使用Lock锁,JVM将花费更少的时间来调度线程,程序的性能更好,而且Lock拥有很多子类有更好的扩展性。

对比Lock和Synchronized我们知道,lock优于synchronized,synchronized又分为代码块和方法,使用代码块性能要更好于方法(因为方法锁住的范围更广,同步操作的时间就会更长,性能自然就更低)。因此在开发中我们应该这么使用:Lock优于 synchronized代码块优于 synchronized方法。

3.AQS + ReentrantLock

  • AQS什么是什么
    AQS(AbstractQueuedSynchronizer)抽象队列同步器,AQS是JDK1.5提供的并发包下加锁和释放锁的核心组件,比如常用的可重入锁ReentrantLock、ReentrantReadWriteLock就是基于AQS实现的。说白了AQS就是一个组件,一个类,他为锁机制的实现提供了核心支持。正是依赖于它,ReentrantLock等才实现了加锁和释放锁的操作。那AQS是怎么实现加锁和释放锁这套机制呢。
  • AQS加锁和释放锁的机制
    AQS的内部有一个int类型的state变量用来表示加锁的状态,初始值是0代表没有线程获得锁,此外还有一个变量用来记录当前加锁的线程,除去他们俩之外还有一个用链表实现的等待队列,用来存储想要获取锁却没有获取到的线程。下面就来说下他们三个是如何配合来保证加锁和释放锁的吧:假设有两个线程:线程一、线程二,此时都想要获取锁。
    ①线程一先获取到了cpu的资源开始执行,先去查看state是不是0,是0的话将state设置为1,然后再用于标记持有锁的线程标记为自己。
    ②线程二此时也发起了获取锁的操作,先去获取statie的值发现值是1,然后再去判断持有锁的线程是不是自己(这里很关键),不是自己则说明这个锁不是自己持有的,线程二进入等待队列。
    ③此时线程一又想获取锁(可重入锁),又请求获取锁,线程一先获取state的值发现是1,然后判断持有锁的线程是不是自己,害,发现正好是自己持有的,则对state做++操作,其余不变,只要是自己持有的锁,当前线程就可以一直加锁,同样在释放锁的时候state也是一直递减的,直到为0表示锁释放完毕。
    ④这是线程一执行完了所有的操作释放了自己持有的锁,state变成了0,持有锁线程变成了null,此时线程二请求加锁,线程二需要先去获取state判断是不是0,是0则直接上锁,设置持有锁的线程为自己。
    这里必须要说明的是加锁的过程,也就是将state从0变为1的过程是一个CAS(Compare And Swap)操作,他是原子性的,故他是线程安全的。
  • ReentrantLock是什么
    ReentrantLock是JDK1.5并发包下提供的线程同步机制类,可以用于和synchronized一样实现同步机制,他比synchronized更轻。ReentrantLock又被叫做可重入锁,因为一个ReentrantLock对象可以重复执行多次lock(),unLock()方法。也就是可以对一个锁加多次,这就是可冲入锁,这得益于他的实现机制,因为AQS中的state支持持有锁线程的加锁。
  • ReentrantLock为什么叫可重入锁
    其实上面已经说了,这里简单的进行概括下,ReentrantLock之所以可以重入是因为AQS的实现机制:当一个线程加锁成功后,AQS用于标记加锁状态的变量state就会变成1,持有锁线程就会变成自己。当再一次执行加锁操作时会先判断state是不是0,不是0则会再判断持有锁线程是不是自己,是的话就支持state的++操作,这就是可重入锁,值得注意的是state的操作是CAS操作,他线程安全,不会被其他线程打断。
  • AQS与Reentrant的关系
    用一张图形容下他们俩的关系就是如下这样,ReentrantLock就是基于AQS来实现的,他们是一种包含关系,AQS为ReentrantLock、ReentrantReadWriteLock提供了锁机制的实现,其实他们相当于对AQS的封装扩展,AQS更像是他们的内核。
    在这里插入图片描述

4.CAS + Atomic原子类

无论是synchronized还是Lock都是锁机制实现的同步操作,在JDK1.5的并发包下还提供了另外一种不使用锁机制来实现的并发安全操作机制,那就是使用CAS+失败重试机制的Atomic原子类系列。我们知道多线程情况下去对基本数据类型进行增删改操作是不安全的,而并发包下提供了AtomicInteger、AtomicLong、 AtomicBoolean等类用于支持并发场景下的数据操作,这些类的实现机制都是依赖于CAS+失败重试,而不是使用锁机制实现的同步操作,那CAS是什么呢?

  • 什么是CAS
    CAS(compare and swap)从字面上翻译CAS就是比较然后交换的意思,其实他的工作原理也就是这个样子,锁机制都是让多线程的操作变成串行化,而CAS却不是,他是先获取变量值,在需要执行变更操作时先去拿这个值与主内存的值进行比较,若是相等再将执行当前线程的操作,不等则需要重新获取然后再执行当前线程的操作,这就是CAS。必须要说的是CAS是一个原子操作,也就是说比较然后设置这个操作是不会被其他线程中断的,它线程安全,此外这种不使用锁来实现的同步机制也被称为乐观锁。相反的synchronized就是悲观锁了。

  • CAS的工作机制
    了解了CAS,还必须要知道CAS是如何保证线程安全的,我们做个场景假设来模拟下CAS的工作流程,需要说的是这个流程是JDK8之前的,JDK8之后对CAS做了优化,但是这套机制还是适用的,JDK8只是将CAS操作变成了分段处理,每段的处理还是现在这个流程,JDK8具体的修改往下看会有介绍。下面先来假设下场景:假设有两个线程:线程一、线程二,正在同时修改AtomicInteger的值。主内存中AtomicInteger值是1。则会有如下场景发生:
    ①.线程一和线程二都拿到了主内存中的AtomicInteger的值是1。
    ②线程一想要修改AtomicInteger的值为2,修改之前先拿到自己工作内存中的1与主内存的1对比,发现相等后,将工作内存和主内存的AtomicInteger都改为了2.
    ③线程二此时却想要将AtomicInteger的值改为3,线程2则先拿着自己工作内存中存储的1去与工作内存中的2对比,发现不相等,不相等则不能设置,而是从新从主内存获取,获取后再次比较发现相等了,然后设置工作内存和主内存的值为3

    这就是CAS的工作机制的流程,因为CAS是原子操作故而保证了线程的安全。只要有一个线程在做CAS操作,那其他线程是不能进行打断的。

  • JDK8对CAS机制的优化 和 LongAdder
    根据CAS的机制,我们可以发现当线程量十分多的时候,CAS的性能就会越来越低,因为CAS是原子操作,就会导致其他线程在不停的获取值,然后比较后发现不相等,再接着从新获取,就会陷入这样的恶性循环。因此在JDK8时对CAS机制进行了优化推出了LongAdder类,该类就是基于优化后的CAS实现的。那JDK8对CAS进行了怎样的优化呢?JDK8针对高并发场景提出了分段CAS和自动分段迁移的方式来提升高并发执行CAS时的性能。那这个分段CAS是个什么意思,自动分段迁移又是什么?来看下LongAdder的工作机制就会清楚了。先来做个场景假设有很多个线程在同时修改LongAdder的值,那么就会有如下场景发生。
    ①当发现有很多线程在进行CAS操作,致使很多线程出现空旋转的情况时,此时会保存一个已经计算出来的值作为base值,并且此时会创建一个cell数组,让一部分线程的计算结果存入一个cell中,这样就可以将所有线程分成好几部分来分开计算(分段CAS)。
    ②当有cell计算失败时,会将线程的操作自动迁移到其他cell中计算(自动分段迁移)。
    ③当所有线程都计算完毕后对base和cell进行合并计算得出最终结果。
    这样就会提升了CAS在高并发下的效率。

  • 为什么要对CAS进行优化
    根据前面假设的场景可以发现CAS在高并发的场景下会让大量线程出现空旋转的情况,从而出现影响性能的情况。因而在JDK8时才对CAS进行优化,新增了LongAdder类。LongAdder的实现机制就是分段CAS+自动分段迁移。这样就大大提高了在多线程场景下的效率,当然了若是线程量比较小的场景我们还是使用原子类AtomicInteger等类即可。无需使用LongAdder。若是了解JDK8中提供的流式操作的同学可能会比较熟悉这个场景,流式操作的底层也是会对流进行分段处理,这其实是一种很常见的并发处理思想,同时也多处用于提升处理效率。

  • 已经有锁了,为什么还要CAS机制
    前面已经说过,synchronized是一种悲观锁,CAS机制则被认为一种乐观锁。悲观锁可以支持代码块、方法级别的同步,自然也是可以在包装的情况下修改字段的值,而乐观锁主要强调的是对单一变量的修改,他们的侧重点不一样,并且在单一变量的修改场景使用悲观锁的代价太高,悲观锁所耗费的虚拟机性能要高出很多。所以才有了CAS的生存空间。

  • AtomicInteger使用示例
    下面只是一个假设的场景,主要是为了验证AtomicInteger的安全性,代码如下:

    import java.util.concurrent.atomic.AtomicInteger;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
        static AtomicInteger atomicInteger = new AtomicInteger(0);
        public static void main(String[] args) throws InterruptedException{
            for (int i1 = 0; i1 < 20000; i1++) {
                new Thread(() -> {
                    System.out.println(Thread.currentThread().getName()+"线程正在操作+1");
                    atomicInteger.addAndGet(1);
                }).start();
            }
            Thread.sleep(1000);
            System.out.println(atomicInteger.toString());
        }
    
    }
    

5.JMM + Volatile

前面介绍过并发编程下必须考虑的三个问题,原子性问题、可见性问题、顺序性问题。我们已经知道synchronized、Lock是可以解决原子性问题和可见性问题的。那顺序性问题如何解决呢?事实上Java提供了Volatile关键字用来修饰变量,就是用来解决顺序性问题的,此外Volatile还可以解决可见性问题。

  • JMM是什么?
    在JMM(Java Memory Model)java内存模型中明确提出了每个线程都拥有自己的工作内存,所有线程共享主内存。线程操作变量都是在工作内存完成的然后才会将结果同步到主内存,工作内存中的变量是主内存中的副本,是一个拷贝值。当有多个线程时,每个线程都在自己的工作内存中操作同一个变量就会出现线程安全问题,造成数据不一致(因为每个线程都是操作自己的值,并不知道其他线程在干什么)。此时我们就需要Volatile关键字,让线程的操作对其他线程都可见,这也就是并发的可见性问题了。
  • volatile解决了什么问题?
    ①解决了可见性问题:被Volatile修饰的变量在线程工作内存中发生变更后,会立即将该变量推送到主内存中,并强制让其他线程引用该变量的地方失效。其他线程想要操作改变量,只能从新去主内存去获取。
    ②解决了顺序性问题:Volatile修饰的变量会禁止指令的重排。
  • volatile的工作机制
    上面介绍了JMM和Volatile的作用,知道了Volatile的作用,我们还需要知道他到底是怎么个流程去解决这个事的呢。先做个场景假设:假设有两个线程:线程一、线程二,他们都想对一个被Volatile修饰的变量obj进行修改,那么就会有如下的场景发生。
    ①若是线程1先抢占了cpu资源,线程一对obj进行了修改,jvm发现obj是被Volatile修饰的,则会强制将obj的值刷新到主内存。
    ②主内存的值被刷新后,jvm还会让线程二中的obj进行强制失效。
    ③线程二终于拿到了cpu资源开始执行修改obj的操作,但此时发现obj指向的对象已经失效了,jvm告诉他你需要从新去主内存获取,然后线程二从新从主内存中同步obj的值,再进行修改,同样他修改后线程一种的obj也会失效。
    ④此时线程一中的obj已经失效了,但是线程一已经不需要操作obj了。则程序正常结束,不会去从新同步obj了。

6.ThreadLocal + ThreadLocalMap

ThreadLocal为甚么要放在这里说呢,他好像和线程同步完全是反着的,之说以放在这里说,一是不值得为ThreadLocal拉开单独讲,二是因为他和Volatile正好也是反着的,就一起说了,可以相互印证,加深记忆。

  • ThreadLocal是什么
    直白的说ThreadLocal就是被用来在多线程场景中做数据隔离的数据载体,通过ThreadLocal我们可以实现每个线程中操作的数据互不影响,使用ThreadLocal的set和get方法,得到的永远都是自己线程内部设置的信息,其他线程设置的信息对当前线程不可见。ThreadLocal的作用常见的有三种
    ①.存储单个线程的上下文信息,我们可以利用他来存储上下文信息,这样我们就可以在线程运行的任何地方都可以拿到这些信息了。
    ②.存储在ThreadLocal的变量线程安全,所以我们可以利用他存储一些线程所必须独有的信息。
    ③.可以使用ThreadLocal来存储引用链比较长的参数信息,当引用链比较长时,尤其是需要通过jdk原有的方法传递,我们无法更改原有方法时,都可以将参数放置在ThreadLocal中,这样我们就可以在需要的地方随时获取了。

  • ThreadLocal的使用
    ThreadLocal是多线程中比较偏门的一个小东西,使用起来也很简单,如下一段简单的代码,在主线程中声明了ThreadLocal的变量,然后就可以在子线程中使用,子线程中都使用ThreadLocal,但是却彼此都不影响,感觉很神奇是吧,其实原理很简单,稍后笔者会介绍ThreadLocal实现线程隔离的原理。

    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
    
        public static ThreadLocal<List<String>> threadLocal = new ThreadLocal<>();
    
        public static void main(String[] args) {
    
            new Thread(() -> {
                List<String> list = new ArrayList<>();
                for (int i = 0; i < 100; i++) {
                    list.add("值"+i);
                    threadLocal.set(list);
                    System.out.println(threadLocal.get().get(threadLocal.get().size()-1));
                }
            }).start();
    
    
            new Thread(() ->{
                List<String> list = new ArrayList<>();
                for (int i = 0; i < 100; i++) {
                    list.add("zhi"+i);
                    threadLocal.set(list);
                    System.out.println(threadLocal.get().get(threadLocal.get().size()-1));
                }
    
            }).start();
    
            //验证:主线程可能会在子线程结束之前结束(验证通过)
            System.out.println("************************************大小:"+threadLocal.get().size());
    
        }
    
    }
    
  • ThreadLocalMap是什么
    在说ThreadLocal线程隔离的原理之前,必须要提ThreadLocalMap,他是ThreadLocal的一个静态内部类,并且在Thread中维护了两个如下成员变量,那为什么要在Thread类中维护ThreadLocalMap 的引用呢,其实就是为了实现线程的数据隔离(需要注意inheritableThreadLocals 不是为了实现线程隔离,而是为了实现共享)。

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    
  • ThreadLocal怎么实现数据隔离
    在不知道原理之前,感觉哇竟然可以让单个变量实现线程间的数据隔离,肯定使用了类似Volatile这样的关键字吧,我们都知道Volatile能让变量在线程间变得可见,那么是不是也有一个关键字可以让变量在线程间变得不可见呢,笔者在未了解这块时就是这么想的,然而发现我错了,JDK使用ThreadLocal实现数据隔离根本不需要什么关键字,比如上面的例子中,先是在主线程中创建了一个ThreadLocal的变量,这里使用的是空参构造,我们点进去这个空参构造,发现里面啥也没有,说明真正的操作不在这里,再然后在子线程中分别使用这个变量设置值,那我们点进set方法看下,set方法的源码如下所示:

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    

    这是我们就会发现先是拿到了当前线程,然后调用getMap方法传入当前线程,这里其实拿到的就是当前线程的ThreadLocalMap了,那有人可能会有这样的疑问,我没创建过ThreadLocalMap的对象啊,往下看,若是map==null的场景会去调用createMap,传入的是当前线程和我们使用set方法往ThreadLocal中设入的值,那我们在看下createMap的源码,如下所示:

        void createMap(Thread t, T firstValue) {
            t.threadLocals = new ThreadLocalMap(this, firstValue);
        }
    

    这个方法只有一行代码,就是创建一个ThreadLocalMap,将键和值给他。然后并且将这个新创建的ThreadLocalMap对象交给当前线程的threadLocals变量,在上面介绍ThreadLocalMap时,我们说过每个线程对象都会有这个变量。就是在这里用的。这样是不是就会发现ThreadLocal实现线程间数据隔离的原因了呢,总结一句话就是:每个线程实例都会有一个ThreadLocalMap对象,这个map中键存的是ThreadLocal,值存的是我们开发时,往ThreadLocal中设入的值。所以我们表面上是用ThreadLocal存的,其实是用每个线程独有的变量来存的,ThreadLocal只充当了map中的键,所以对数值的存取没有影响,从而实现了线程间的数据共享。

  • ThreadLocalMap底层是什么结构
    ThreadLocal底层是一个Entry数组,每一个Entry都有键和值,键用来存放ThreadLocal,且键都是弱引用的,值就是通过ThreadLocal的set方法存入的值。这里需要说的是Entry的键都被设置成了弱引用,这种操作一般认为会导致value的内存泄露。

  • ThreadLocalMap怎么做到不使用链表来解决hash冲突的
    前面说了,ThreadLocalMap的底层其实是数组,我们都知道HashMap的底层是数组+链表+红黑树,当没有hash冲突时使用数组存储键值对,当冲突时就会引入链表,当链表长度大于8数组长度大于64就会转化红黑树。那ThreadLocalMap只有数组怎么解决hash冲突呢,ThreadLocal其实是这么设计,当有冲突时就判断下他的后一位是不是空,是的话就存到后一位,若是不为空则继续往后判断直到有空的位置就存进来。这种解决方法简单粗暴,效率不高,当数据量大时存取的效率都非常低,但是ThreadLocalMap并不会存放很多东西,这么设计其实也无可厚非,笔者认为也没有什么问题。

  • ThreadLocal存在什么问题
    如下图所示,当创建ThreadLcoal的线程执行完毕时,ThreadLocalRef这个引用就会失效,那么此时当前线程中存储的ThreadLocal就只有Entry中的key指向他了,而这个引用指向又是一个弱引用。所以下次GC时就会回收掉ThreadLocal,但是因为当前线程未结束那么线程中的Map(ThreadLocalMap)的引用就还存在,他存在entry就存在,所以value就不会回收,但此时key已经没有了,所以就导致了内存泄露。
    在这里插入图片描述

  • ThreadLocal导致的内存泄露的真实原因是什么,怎么解决
    键被回收了,但是Entry的引用还在,所以value就会一直存在,所以导致了内存泄露,具体原因上面也已经说了。解决就是我们使用完ThreadLocal之后就必须使用remove方法。这样就可以解决内存泄漏的问题,尤其在使用线程池时更为必要使用ThreadLocal的remove方法了,因为线程不会被销毁,所以不使用remove方法,下次线程启用时,里面存的其实是上一次线程执行时的内容。但是笔者这里要说一点自己的看法,其实我感觉这个内存泄露并不严重, 为什么呢,因为ThreadLocal的get方法中会去清理掉为空的键值对,但是这可能会有一个延时,当然也有清理不掉的场景,但是造成的内存泄露不会多严重,不过最保险的办法还是使用remove方法。

7.自旋锁、适应性自旋锁、偏向锁、轻量级锁、重量级锁、消除锁、锁粗化

首先明确一点所有的这些都是用来描述synchronized实现机制的,JDK1.6开始synchronized实现的锁有这几种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。那自旋锁、适应性自旋锁,还有偶尔会听到的消除锁、锁粗化又是什么呢?他们这四个并不是锁的状态,而是虚拟机为了提升锁效率在适当的场景对锁机制的优化。synchronized是内置锁,他的锁获取都在对象的头部中,对象头主要存储了两类信息,一类是标记字段,一类是类型指针,我们所说的偏向锁、轻量级锁、重量级锁就在这里,重量级锁持有的是Monitor锁,而偏向锁和轻量级锁都是使用的CAS机制。 下面我们就来一起看下这些对锁机制的优化以及synchronized的各种状态。

  • 自旋锁
    线程的阻塞和唤醒需要耗费cpu不小的代价,而频繁的阻塞和唤醒就更耗费cpu的性能了,因为在实际的场景中加锁状态可能只会持续很短一段时间,锁就会被释放。在这种情况下我们频繁的将线程变成阻塞然后再频繁的唤醒线程是非常cpu资源的,因此在JDK1.4时引入了自旋锁(默认关闭)。那什么是自旋锁呢?当一个线程获得了锁,未获得锁的线程不让他进入阻塞状态,而是给他一段空指令让他循环跑起来,稍许等待后锁被释放,空转的线程就可以获取锁,而不用将等待线程进入阻塞状态再唤醒,这就是自旋锁。
    我们可以发现使用自旋锁确实可以优化锁机制,因为使用自旋锁可以省去cpu将线程阻塞和唤醒的性能消耗,那么若是大部分线程需要等待的时间都很长怎么办呢?当然虚拟机是支持配置自旋锁的自旋次数的,但是我们可能并不能精准的预测自旋的时间,在这种考虑下JDK1.6对自旋锁进行了改良变成了适应性自旋锁(默认开启,默认自旋次数10)。
  • 适应性自旋锁
    适应性自旋锁是在自旋锁的基础上发展而来,它是默认开启的且默认自旋次数是10次,但是这个自旋次数并不是一成不变的,它可以根据上一次加锁的旋转次数而动态调整,若是上一次旋转10次就等到了锁,那下一次可能会多旋转一两次以防止突发情况,若是旋转了很多次只有很少机会获取到了锁,则会调小旋转次数,以防止因不必要的自旋影响cpu的性能。
  • 锁粗化
    锁粗化可以看成是对代码的一种优化机制,当我们在一段代码中重复使用加锁操作时,jvm会对这种行为进行优化,会将这种操作变为在目前锁的作用范围外施加一个更大的锁,而不用频繁的去加锁和释放锁。这就是所粗化机制。
  • 消除锁
    那什么是消除锁呢,顾名思义就是将锁机制消除。在一些场景中可能会因为开发者的不清楚,而对某些单线程操作施加了锁机制,比如常用的HashTable、StringBuffer等线程安全的类。他们都是默认使用锁的,但是因为开发者不清楚去使用这些类开发了单线程程序。那么在这些场景下JVM会将锁机制消除,当然了具体的判断会很麻烦,不会像笔者这样一带而过,这里只阐述消除锁的思想和举例。
  • 重量级锁
    自旋锁、适应性自旋锁、锁粗化、消除锁都是在不同层面对锁机制的优化,而重量级锁才是对锁的实现机制进行解析的开始。那为甚么要先说重量级锁呢,因为最开始时只有重量级锁,后来才有的轻量级锁、偏向锁,他们的发展是一个逆序的出现过程。言归正传,重量级锁是内置锁,在jdk1.6之前我们可以认为monitor直接对应的是底层操作系统中的互斥量,这种同步机制实现的成本非常高。包括线程的切换、阻塞、唤醒等。因而才会有了轻量级锁。
  • 轻量级锁
    当线程并发量并不高时,我们使用重量级锁无疑是不明智的,此时就可以使用轻量级锁(JVM自动的行为,无需认为控制),轻量级锁基于CAS机制实现,当线程并发量不高,锁竞争不激烈时,就是这种机制了。每个线程都会在栈(线程私有)中维护一个Lock Record 由它去尝试拷贝对象头信息,成功了则说明目前没有锁,将锁变成轻量级锁。这个过程其实就是一个CAS过程(JDK以后有队CAS优化,也可以支持锁竞争激烈的情况,具体要什么时候转化为重量级锁也说不准)。
  • 偏向锁
    实践表明在80%的场景下,多线程中调用可能都是一个线程在执行,在这种场景下使用的就是偏向锁,偏向锁只需要执行一次CAS操作,当获取了偏向锁的线程再次进来时就不需要加锁了,而是直接执行(这里优点类似于AQS的工作机制),这样就只有一次CAS操作,比轻量级锁性能会更高,不过偏向锁更新的值是对象头中的偏向线程id和是否持有偏向锁(这么一说是不是和AQS更像了呢),不过万一又有其他线程进来怎么办呢,这时偏向锁就会膨胀为轻量级锁。

介绍了偏向锁、轻量级锁、重量级锁,做个小小的总结,偏向锁使用CAS机制更新对象头中的是否持有偏向锁、偏向线程ID,他的工作机制与AQS类似。当有其他线程进来,偏向锁就会膨胀微为轻量级锁,轻量级锁也是基于CAS实现的,他更新的是锁记录,锁记录存储在栈中,并发线程不是很多时JVM会使用轻量级锁来实现,当线程量比较巨大,锁竞争太过激烈是轻量级锁会膨胀为重量级锁。

8.读锁与写锁

锁也可以划分为互斥锁和共享锁,当有线程持有锁时是否允许其他线程继续访问来划分的。那就有疑问了?如果一个线程都持有了锁还允许其他线程允许访问,那么还加锁干什么呢?直接不加锁不就完事了吗?事实上还是略有不同。共享锁便是在有线程获得锁之后仍然支持其他线程的访问,但是这个访问并不是支持所有,只是支持部分,怎么说呢,相当于限流了吧,此时只允许部分安全的请求进来。我们说的读锁其实就是共享锁,写锁就是互斥锁。当一个线程加了写锁时,其他线程就只能排队等着了,此时线程执行就是串行化的。当一个线程加了读锁时,那么其他线程都可以读,但是写不支持,加了读锁相当于是只拦截其他线程的写操作,而不拦截其他线程的读操作。这么做的原因也很好理解,要是我正在读你进来把数据改了,不就造成数据脏读了吗,要是我正在写你进来读也是一样的情况。所以只有读的时候允许其他线程进来读,写的时候是不允许其他线程进来写和读的。

  • 读锁
    当一个线程加了读锁时,允许所有其他线程的读操作,但是不允许其他线程的写操作,因为并发读不会有安全问题,但是写会造成读的混乱。如下代码便是读锁:
  • 写锁
    当一个线程加了写锁时,不允许其他所有线程的读写操作,可以认为此时所有线程的操作都必须是串行化的。如下所示便是一个简单的加读写锁的多线程操作,这段代码没有什么实际意义,只是为了展示下jdk提供的读写锁,其中ReentrantReadWriteLock,我们可以看到他和可重入锁都是locks包下的锁,都是jdk1.5提供的,他们底层都是基于AQS来实现的,且都支持锁的公平操作。
    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.locks.ReentrantReadWriteLock;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
    
        static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
        static List<Integer> list = new ArrayList<>();
        public static void main(String[] args) throws InterruptedException{
    
            new Thread(() -> {
                for (int i = 0; i < 100; i++) {
                    //加读锁
                    lock.readLock().lock();
                    list.add(i);
                    lock.readLock().unlock();
                }
            }).start();
    
            new Thread(() -> {
                for (int i = 0; i < 100; i++) {
                    //加写锁
                    lock.writeLock().lock();
                    list.add(i);
                    lock.writeLock().unlock();
                }
            }).start();
    
        }
    
    }
    
  • 读写锁的实践
    看完了读锁和写锁的介绍大致可以猜到,读写锁适合那些读比较多写比较少的场景中,在这样的场景中使用读写锁可以大大提高系统的并发量,提升系统性能。仔细一想不难发现我们常用的注册中心中就是这么个场景。在微服务项目中,都需要注册中心来作为服务的注册和发现的中间商,注册中心通过在内存中维护一个注册表来支持服务的注册与发现,服务注册时往注册表中写入数据,服务发现时去读注册表的数据,我们可以发现在实际的场景中基本都是服务发现的操作,服务注册在项目启动时执行一次就够了。所以在注册中心中使用读写锁,是一个很棒的选择。像我们常用来为dubbo提供注册发现的zookeeper和springcloud得注册中心Eureka、Consul等都是基于读写锁来实现注册表的并发操作的,但是光使用读写锁,以电商场景的并发量来说还是不够,所以他们还有缓存的解决。用来对数据进行分级,从而来支持并发的读写操作,来提升服务注册的读写能力,使用缓存来解决并发问题,并不是一个新鲜概念,这种场景还是比较常见的,比如JVM中的工作内存其实就是一种缓存策略,Spring同样有缓存策略,Mysql也有缓存策略,比如我们使用Redis不也是为自己的项目加缓存吗,思想上都是相同的,都是为了让系统可以运行的更流畅。

9.公平锁和非公平锁

公平锁和非公平锁从字面上看就可以看出他描述的是加锁这个操作是否是公平的,所谓公平就是先来先得,后来的排队等,相反的随便插队那肯定是不公平的了,下面就来一起看下:

  • 非公平锁
    synchronized实现的加锁机制就是非公平锁,此外ReentrantLock默认也是非公平锁,那非公平锁是怎么工作的呢,我们就以ReentrantLock来举例吧:
    ①线程一先执行了加锁操作,将AQS中的state通过CAS操作设为了1,持有锁线程设置为了自己,此时线程二过来发现加不了锁只能进入到了等待队列。
    ②当线程一释放锁时,又来了个线程三,此时线程二和线程三都是就绪状态,他们都可以获取锁,不好意思,由于巧合线程三拿到了锁,将state设为了1,持有锁线程也就变成了线程三。线程二就得继续进入等待队列。
    在这个过程中我们发现,明明是线程二先来的,最后线程三却完成了插队优先执行了,这种就是非公平锁,非公平锁中,所有线程的加锁操作机会都是均等的。

  • 公平锁
    上面已经说了非公平锁的工作流程,其实公平锁的工作机制是从第二步才可使不一样的,也就是有其他线程尝试插队时才会不一样。
    ①线程一先执行了加锁操作,将AQS中的state通过CAS操作设为了1,持有锁线程设置为了自己,此时线程二过来发现加不了锁只能进入到了等待队列。
    ②当线程一释放锁时,又来了个线程三,这是若是等待队列不为空,则应该让等待队列中的线程执行,让线程三进入等待线程继续等待。
    这样来说对于线程二才是公平的,那怎么实现公平锁呢,我们只需要在创建ReentrantLock时传入true即可。这样加锁的机制就是公平的了。

    ReentrantLock  lock = new ReentrantLock(true);
    
  • 对比公平锁和非公平锁
    知道了公平锁和非公平锁的区别,我们还需要知道他们的优缺点这样才能确定在什么场景下使用他们:
    ①公平锁的优点是等待线程不会出现饿死的情况,但是公平锁的效率相对于非公平锁会低一些,等待线程中除了第一个线程以外其他线程都会阻塞,因此cpu需要唤醒阻塞线程,而非公平锁中是有机会线程不阻塞直接执行的。
    ②非公平锁的优点是效率相对较高,非公平锁可能会省去很多唤醒阻塞线程的操作。但是与之相对的,非公平锁可能会造成线程饥饿,可能会有线程要等非常久才能加上锁。
    所以总结公平锁和非公屏锁的优缺点我们可以发现,若是要求效率而对线程的执行没有什么必然的先后顺序我们应该使用非公平锁。笔者认为需要考虑线程的执行顺序的场景应该很少,所以我们正常中使用非公平锁即可。

10.显示锁和隐式锁、乐观锁和悲观锁

这些概念也是比较常见的,锁这里也是总结下,这些都是在概念层面对锁的划分,并不是一种技术的实现,下面就来总结下这几个概念分别是从什么场景对锁进行划分的。

  • 显示锁
    顾名思义,显示的指明了加锁的过程的锁都是显示锁,像JDK1.5提供的并发包下的可重入锁(ReentrantLock)、读写锁(ReentrantReadWriteLock)等都是显示锁,因为加锁和解锁的过程都是显示的。
  • 隐式锁
    与显示锁相对应的就是隐式锁了,隐式锁自然就是加锁和解锁的过程都是隐式的了,是在底层实现的,比如Synchronized实现的锁,他的加锁和解锁我们是看不见的,实际上synchronized在为代码块加锁时,是通过两个字节码指令实现的,一个是monitorenter、monitorexit来实现加锁和解锁,synchronized修饰方法时则是通过方法表中的ACC_SYNCHRONZED来实现的,这些实现对于开发者来说都是隐式的,所以synchronized是隐式锁。
  • 乐观锁
    虚拟机乐观的认为当然想要修改的值并没有被其他线程锁修改,因此线程只需要拿到然后比较下就可以操作,基于这种场景实现的锁被称为乐观锁,这种场景其实就是CAS的底层机制:比较然后交换,若是比较成功就进行数值交换,而不是像synchronized那样去加一个重量级的锁,然所有操作变得串行化。我们可以发现当只有一个线程来来回执行时(并发情况下总是一个线程抢到了资源,二八定律),使用乐观锁
    的效率无疑是很高的,当然在高并发的场景下JDK8对CAS机制也进行了优化,使其支持分段CAS和自动分段迁移来让乐观锁的性能更改好。我们常见的JUC(JDK1.5提供的并发包)包下面的的可冲入锁、读写锁等待都是乐观锁,他们都是基于CAS来实现的(都基于AQS实现,AQS基于CAS实现)。
  • 悲观锁
    知道了乐观锁其实悲观锁也就知道了,虚拟机悲观的认为我要是不进行串行化操作,一个线程来操作数值时这个数值肯定不是想要的了,所以必须为待操作的内容执行串行化,也就是同一个时间只能有一个线程在操作,其他现场都必须排队等着,这就是悲观锁。悲观锁所耗费性能会比较高,比较影响系统性能。比如synchronized便是一种悲观锁,但是这个说法现在也不完全对了,因为在JDK1.6时,对synchronized进行了优化,synchronized初始状态使用偏向锁,当偏向锁不能满足时才会膨胀微轻量级锁,当轻量级锁不能满足时才会膨胀微重量级锁,重量级锁才是我们通常所说的synchronized,而偏向锁和轻量级锁都是基于CAS来实现的,他们都是轻量的。

11.对锁的总结

对于线程并发这块,笔者写了14000多字,也耗费了很长时间,最近工作之余一直都在整理,也耗费了很长时间。关于这一部分的内容大部分都是在说锁有关的问题,其实锁并不多只有synchronized、Lock两种,然后根据Lock又有很多实现比如可重入锁ReentrantLock、读写锁ReadWriteLock(并不是直接继承),又根据Lock的原理介绍了AQS和CAS机制,而说到了CAS就必须得说下Atomic原子类,原子类是基于CAS实现的安全数据类型有AtomicInteger、AtomicLong等,介绍了Lock的底层原理又介绍了synchronized的工作原理:synchronized在jdk1.6后会如何从偏向锁到轻量级锁再到重量级锁的。锁的工作机制和原理都介绍完了以后,又对锁的各种划分做了介绍,比如锁有重量级锁和轻量级锁划分这是根据实现机制来划分的;锁有显示锁和隐式锁的划分这是由锁在书写时加锁解锁的可见性来划分的;锁有公平锁和非公平锁的划分这是根据锁竞争时是否遵循了先来后到的原则来划分的;锁有乐观锁和悲观锁这是根据是否认为操作数据的线程一直可能是一个的场景来划分的,同一个线程操作没有并发问题线程安全是乐观锁(这里有个二八定律的问题)。等等这些都是对锁在不同层面上的一个划分。此外还介绍了JMM和Volitale,这块是提到锁必须要说的事情,因为锁解决的是并发中的原子性、可见性问题,而Volatile解决的是可见性和顺序性问题,他们一起才可以保证多线程情况下的绝对安全,整个篇幅总结下来可以说很长,但是讲的并不是特别深入,都是原理上来进行论述,好了继续来总结线程中的其他问题。

五、线程中常见问题

1.死锁

  • 什么是死锁
    死锁是线程安全中很可能会碰到的问题,之所以会产生死锁是因为线程一持有了A锁,然后想去获取B锁。而线程二持有了B锁,想去获取A锁,这样他们都在等待对方释放锁,但是谁都不会释放锁这样就陷入了一个死循环就成了死锁。
  • 死锁举例
    如下方所示代码,便是一个死锁的场景,一旦程序运行就会被锁住无法继续执行。
    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.FutureTask;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
    
        public static void main(String[] args) {
            new Thread(() -> {
                synchronized (A.class){
                System.out.println("我是线程一,我持有了A锁");
                    try {
                        Thread.sleep(1000);
                        synchronized (B.class){
                            System.out.println("我是线程一,我持有了B锁");
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
    
                }
            }).start();
    
    
            new Thread(() ->{
                synchronized (B.class){
                    System.out.println("我是线程二,我持有了B锁");
                    try {
                        Thread.sleep(1000);
                        synchronized (A.class){
                            System.out.println("我是线程二,我持有了A锁");
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    
        class A{}
        class B{}
    
    }
    
  • 如何避免死锁
    从上方的代码中我们可以看出之所以会出现死锁,最主要的原因还是锁的使用混乱
    ①当线程持有了锁一去申请锁二时,若是申请不到应该释放锁一,以避免死锁。
    ②多个线程避免使用相同的锁,以防止锁重用导致的死锁问题。

2.成产者消费者问题

注册中心就会伴随生产者和消费者的问题,一个人生产完了,另一个想要即时拿走,我就得告诉其他线程你可以拿走了,但是zookeeper是通过加读写锁来实现的啊,好像又和生产者消费者又不像,生产者消费者好像是一个阻塞等着另一个完成。zookeeper应该不是生产者和消费者了。消息队列好像是生产者和消费者的问题,每当有消息进来,就要立马监听到,然后处理消息。嗯这肯定是个生产者消费者问题,注册中心也可能会有这个问题。先待定。

  • 什么是生产者消费者模式
    其实说白了就是线程通信,而线程通信所依赖的方法就是wait、notify、notifyAll方法。通过这些方法,二个或者多个线程间可以实现通讯的功能,如:线程一处理完之后进入阻塞状态然后等待线程二的执行,线程二执行完进入阻塞再通知线程一进行继续执行,这样就实现了线程的交替执行,这种模式被称为生产者消费者模式。
  • wait方法、notify方法、notifyAll方法
    wait方法,释放当前线程锁持有的锁,让线程进入到阻塞队列。
    notify方法,通知因wait方法进入到阻塞队列里的线程你可以继续执行了。
    notifyAll方法,通知所有因wait方法进入到阻塞队列的线程都可以继续执行了,使用与一对多的场景,比如一个生产者多个消费者。
    线程通信时就是利用
  • 管程法解决生产者消费者问题
    管程法就是使用一个仓库来存储生产者生产的东西,当仓库存储的数量达到一定程度后就停止生产等待消费者消费,在消费者消费到一定程度后就等待生产者生产,这就是管程法来解决的生产者消费者问题。示例代码如下,一个仓库类RepositoryInn ,里面包含了生产和消费的方法,生产多少和消费多少停止都在这里控制,而消费者线程和生产者线程都只是负责调用两个方法就行。不过这里有两点需要注意:①wait方法必须在上面,因为当线程从阻塞队列里面出来时是从wait方法继续执行的,所以我们判断线程进入阻塞状态的操作必须是在notify方法的上面。②我们都知道仓库中的生产方法和消费方法都必须加锁,这里加的是synchronized锁,那么加可重入锁行不行,为什么要加synchronized锁呢?这里必须要说的是必须加synchronized锁,因为wait方法释放的锁是对象锁,就是synchronized加的锁,而且不加锁肯定不行,因为只有加锁线程才能实现交替执行的效果,这里加锁其实不是因为并发问题,而是因为让线程实现交替执行的目的,有很多人说这里加锁是为了保证多线程的线程安全,这种理解是错误的。
    public class TestThread {
        public static void main(String[] args) {
            RepositoryInn repositoryInn = new RepositoryInn();
            new Thread(new Productor(repositoryInn)).start();
            new Thread(new Consumer(repositoryInn)).start();
        }
    }
    
        class Productor implements Runnable{
    
            RepositoryInn repositoryInn;
    
            public Productor(RepositoryInn repositoryInn){
                this.repositoryInn = repositoryInn;
            }
    
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    repositoryInn.product("手机"+i);
                }
            }
        }
    
        class Consumer implements Runnable{
    
            RepositoryInn repositoryInn;
    
            public Consumer(RepositoryInn repositoryInn){
                this.repositoryInn = repositoryInn;
            }
    
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    repositoryInn.consumer();
                }
            }
        }
        class RepositoryInn{
            List<String> list = new ArrayList<>();
    
            //生产
            public synchronized void product(String str){
                if(list.size()==5){
                    //满了,停止生产等待消费者消费
                    try {
                        this.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("生产了:"+str);
                list.add(str);
                this.notifyAll();
            }
    
            //消费
            public synchronized void consumer(){
                //判断仓库是否为空,为空等待生产
                if(list.size()==0){
                    try {
                        this.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("消费了:"+list.get(list.size()-1));
                list.remove(list.size()-1);
                this.notifyAll();
            }
    
    }
    
  • 信号灯法解决生产者消费者问题
    这两种方法的实现其实都是大同小异,都差不多,信号灯法就是借助一个boolean类型的变量来实现生产者消费者交替执行的操作。初始状态下,可以让true进入生产,消费者等待,生产完更改为false,false则是进入消费生产者等待,这样就可以实现生产者和消费者线程的交替执行了,示例代码如下,这里有两点必须要提下①信号灯类必须是个单独的类,不能是main方法所在的煮类,因为这样就会造成在自己main方法里操作this的锁,这样编译会过不去。②使用信号灯法时看着比较绕,一会true一会false的,但是只要理清一条线就可以,那就是:对于生产者:false等待,true时生产;对于消费者:true时等待,false时消费即可。
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread {
        public static void main(String[] args) {
            Light light = new Light();
            new Thread(new Productor(light)).start();
            new Thread(new Consumer(light)).start();
    
        }
    }
    class Productor implements Runnable{
        Light light;
        public Productor(Light light){
            this.light = light;
        }
    
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                light.product("手机"+i);
            }
        }
    }
    
    class Consumer implements Runnable{
        Light light;
        public Consumer(Light light){
            this.light = light;
        }
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                light.consum("手机"+i);
            }
        }
    }
    
    class Light{
    
        //信号灯,true:进入生产,false:进入消费
        volatile static  Boolean flag = true;
    
        //生产方法
        public synchronized  void product(String str){
            if(!flag){
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
    
            System.out.println("生产了:"+str);
            flag = false;
            this.notifyAll();
        }
    
        //消费方法
        public synchronized void consum(String str){
            if(flag){
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
    
            System.out.println("消费了:"+str);
            flag = true;
            this.notifyAll();
        }
    }
    
  • 消息队列是生产者消费者的模式?
    说到生产者消费者很容易想到消息队列,但是其实消息队列并不是生产者消费者,因为消息队列的消息接收是异步的,不会跟消息的发送方同步处理结果,他们之间并不构成相互等待的场景,消息队列的原理也不是生产者消费者的模式。

六、经典的线程池与手写线程池

在实际的开发中是不可能让我们直接写线程的,都是使用线程池来管理线程,什么是线程池呢?线程池是一种线程的池化技术,这种池化思想在实际开发中还是很常见的,比如字符串常量池、数据库连接池等都是这种思想。都是随用随取的思想,而不是用的时候再去创建。因为用的时候再去创建一来增加了程序的响应,二来cpu压力增大都是不利于程序更好的运行的。说到这里就总结下使用线程池的优点吧:
①提升响应速度。
②提升系统性能,减少cpu压力,线程是十分珍贵的资源,来回的创建销毁很耗费性能。
③便于管理线程,使用线程池可以对线程进行统一管理。

1.ThreadPoolExecutor 线程池执行器

介绍线程池之前必须要先介绍这个东西,因为像被大多数人经常提起的线程池中的定长线程池(newFixedThreadPool)、单例线程池(newSingleThreadPool)、缓存线程池(newCachedThreadPool)都是基于ThreadPoolExecutor来实现的,其实他们的本质都是TheadPoolExecutor,只是被传入了不同的参数而已,阿里开发手册也有规定,不允许直接使用jdk提供的线程池,而是去使用ThreadPoolExecutor。为什么要禁止使用jdk提供的线程池呢,最主要的原因还是怕开发者不清楚各个线程池实现的优缺点而造成了性能上的损失,说完ThreadPoolExecutor之后,笔者会卓一介绍常见的线程池并总结他们的优缺点,以及可能造成的问题,ThreadPoolExecutor最长可以有七个构造参数,下面分别来解读下这七个参数:

  • corePoolSize:核心线程数,表示一个线程池中最基本的线程数量,会随着线程池的创建而创建这些核心线程,这些线程不会被销毁,当不用时他们会处于非活性状态,但是一旦有任务进来,他们就会立马被激活用来执行任务。
  • maximumPoolSize:最大线程数,表示当前线程池同时间最多可以有多少线程在工作,当任务数量大于核心线程时,线程池就会创建新线程用来工作,这个创建的最大值就是最大线程数减去核心线程数,也就是说线程池内存在的最多的线程数就是这个值。
  • keepAliveTime:线程活性时间,这个参数主要对非核心线程来设定的,对于核心线程并不好使,这个值是一个long类型的值。
  • unit:上面的参数表示线程的活性时间,这个参数表示的是时间的单位,同长使用TimeUtil来获取枚举类型的时间单位。
  • workQueue:阻塞队列,也可叫工作队列,当线程达到了最大线程以后,任务就会进入阻塞队列中等待,有新的线程被释放了,队列里的任务就可以使用新的线程来执行了。该参数需要传入BlockingQueue的实现类。
  • threadFactory:线程工程,线程池中的线程从何而来呢,就是通过线程工厂来创建的,线程工厂需要传入ThreadFactory的实现类。
  • handler:拒绝策略,当线程达到了允许的最大数量,阻塞队列也已经满了的时候,还有任务过来,这时候就需要阻塞策略来解决了,阻塞策略需要传入RejectedExecutionHandler的实现类,常见的阻塞策略有5种,不过我们也可以自己来实现自己的阻塞策略,这样做可以更符合实际需要。

2.定长线程池:Executors.newFixedThreadPool(int n )

先展示下定长线程池的实现,如下:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

上面已经说过了ThreadPoolExecutor这个类了,他才是真正实现线程池的类,定长线程池的实现是通过将核,心线程数和最大线程数都绑定为nThreads来实现定长的,同时活性时间为0,并传入一个链式阻塞队列。这样构成了一个线程池,不过这里只传入了5个参数,还有一个线程工厂和一个拒绝策略没有传,当不穿时,使用的都是默认值,一个使用默认工程(Executors.defaultThreadFactory()),一个使用默认策略(直接抛弃,抛出异常:AbortPolicy)。这样就构成了定长线程池,知道了所有参数的意义,再来看定长线程池就感觉很简单了。

  • 定长线程池可能会OOM
    为什么说定长线程池可能会OOM呢,因为定长线程池默认使用的阻塞队列,他的队列长度是是int的最大值231-1,这么长的阻塞队列若是内存不顾很容易造成OOM。

3.单利线程池:Executors.newSingleThreadExecutor()

先展示下单利线程池的实现,如下:

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

看到上面的代码,其实就很简单明了了,他的核心线程数和最大线程数都是1,这样自始至终就都只会有一个线程用来执行任务,所以叫单例线程池。

  • 单例线程池也可能会OOM
    原因和定长线程池一样,因为他们传入的阻塞队列都是默认的构造器,这个构造器的队列长度就是int类型的最大长度231-1,可以想象一下这是个非常巨大的对象。这个队列里再存储完变量,那是得多巨大,很容易OOM。

4.缓存线程池:Executors.newCachedThreadPool()

先展示下缓存线程池的实现,如下:

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

为什么叫缓存线程池呢,因为每一个线程使用完以后还会缓存60秒来等待有无任务可以分配到他,若是有则会继续工作,没有的话,在60s以后该线程就会被销毁。从传入的参数我们可以看出缓存线程池是没有核心线程的,但是他的最大线程是int的最大值231-1,这代表着若是使用缓存线程池最大可以创建出231-1个线程。

  • 缓存线程池可能会让cpu爆炸
    在上面已经展示了缓存线程池最多可以创建231-1个线程,线程是十分珍贵的资源,就目前的服务器而言,线程数远远不用到达231-1这个数,cpu就会崩溃了。所以使用缓存线程池也是有风险的。

5.定时线程池:Executors.newScheduledThreadPool(int n)

先展示下定时线程池的实现,如下:

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

这里只能看到我们是传入了一个核心线程数来构建一个线程池,但是这个线程池并不是使用ThreadPoolExecutor,而是ScheduledThreadPoolExecutor。而光看这里看不到定时线程池的实现原理,那我们继续看下ScheduledThreadPoolExecutor的实现,如下:

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

这里就有些我们想要看到的信息了,这里可以看到,核心线程数是我们传入的,最大线程数是int的最大值231-1个,然后最后还传入了一个延时队列,看到延时感觉就找到了定时线程池的实现了。那这是就会有疑问了,我也没传入时间之类的参数啊,其实时间的参数是在执行时传入的而不是创建线程池是传入的,这和其他几个线程池略有区别,下面展示下延时一秒执行的代码。

import java.util.concurrent.*;
import java.util.concurrent.Executors;

/**
 * @author pcc
 * @version 1.0.0
 * @className TestThread
 * @date 2021-06-28 16:33
 */
public class TestThread {
    public static void main(String[] args) {
        ScheduledExecutorService executorService3 = Executors.newScheduledThreadPool(3);
        executorService3.schedule(() ->{
            for (int i = 0; i < 100; i++) {
                System.out.println(i);
            }
        },5, TimeUnit.SECONDS);
        
    }
}
  • 定时线程池可能也会让CPU爆炸
    我们已经知道定长线程池和单利线程池都有可能会OOM,缓存线程池可能会cpu爆炸,定时线程池同样可能会让cpu爆炸,因为他的最大线程数也是int的最大值231-1个。

6.阿里为什么不推荐直接使用线程池

看到这里相信大家已经知道为什么不推荐使用jdk提供的线程池了,因为大部分每种都有弊端,都做不到业务上的百分百契合,当然了还有很多线程池笔者没有列出来,而且jdk1.8以后也增加了一部分线程池,但是无论是哪种都没有我们使用ThreadPoolExecutor来自己实现线程池来的更好,我们可以根据自己的实际业务,来判断核心线程数应该给与多少,最大线程数应该定到多少合适,并且根据实际场景来设置非核心线程的存活时间,设置自己的的拒绝策略等。

7.提交方法submit和execute、schedule

  • submit
    该方法可以用于提交Runnable实现的多线程,也可以用于提交Callable实现的多线程,同时用于提交Callable实现的多线程时,可以用Future 来接收返回参数,然后通过future对象的get方法就可以得到返回对象。
  • execute
    而该方法就只能用于提交Runnable实现的多线程。
  • schedule
    这个方法是专门用于定时线程池的提交而使用的,不是通用的,不过定时线程池也可以使用上面两种来提交,只不过定时使用上面俩来提交就默认不使用时间策略了。

8.关闭方法shutdown和shutdownNow

  • shutdown
    调用shutdown方法后,线程池的状态变为SHUTDOWN,此时线程池会将正在执行以及阻塞队列中的线程全部执行完毕,但是不接受新的提交,此时提交会报拒绝异常。
  • shutdownNow
    调用shutdownNow方法后,线程池的状态变为STOP,同时对正在执行的线程调用他的interrupted方法,我们知道该方法只是改变线程的阻塞状态,并不会强制终止线程,该方法必须配合阻塞状态的判断,sleep等方法才能退出线程的执行。经笔者测试也是没有立即结束的,所以可见该方法并不会让正在执行的线程立即停止,可能还会继续执行,此外等待队列的线程不会执行,新任务也拒绝添加,添加新任务同样是拒绝异常。

七、集合与线程并发

说到多线程就必须得提集合,集合是任何一个语言都会有的数据结构,java中自然也是存在的,而且集合是使用最频繁的数据存储结构可以说没有之一,在多线程的程序中使用集合就必须要考虑线程的安全问题,那么我们就必须要清楚怎么使用集合才是正确的,因为有的集合线程安全我们可以直接在多线程使用比如Vector、HashTable等,而有的线程不是线程安全的我们不可以直接使用,若是使用必须配上加锁处理,比如ArrayList、set、HashMap等。而每次都为ArrayList、HashMap等线程不安全的集合进行加锁还是很繁琐的操作,因此JUC包下面还提供了很多线程安全的集合可以 供我们直接使用比如ConcurrentHashMap等,下面我们就来一起看看JDK提供的这些集合。

1.JDK提供的基础集合类

先上一张图来理解下List与Set,该图来源网络,图总结的比较好这里就直接用了,原作者已不可考究,通过这个图看下来就很明了了,List的特点就是存入和取出顺序一致(因为底层是数组和链表所以可以一致)、元素可重复(同样因为底层是数组和链表所以可以重复),而set则是存入和取出不一致(因为底层是hash表),元素不可重复(hash表重复会覆盖)。当然了这也只是总结的大概,具体到每一个集合的实现还略有不同,下面我们就来一个个看下他们的特点。
在这里插入图片描述

  • List

  • ArrayList:
    ArrayList是使用频率最高的List集合,ArrayList的最典型特征是线程不安全,元素的存取有序,数据可重复放插入。ArrayList底层使用数组实现,因为使用数组实现所以他的插入删除比较慢,插叙比较快,所以它比较适合查询操作比较频繁的场景。ArrayList有两个构造方法,一个无参构造一个是有参构造,无参构造构造一个空的数组,有参构造构造一个指定参数的数组(还有一个直接将集合转化为ArrayList的构造),不过即使我们使用指定大小的有参构造创建一个ArrayList,若是我们声明的大小小于10的话,在第一次调用add方法后,ArrayList底层会默认进行扩容到10,所以还是建议在使用ArrayList时不要设置10以下的容量,因为扩容需要调用本地方法,并将原来数组中的所有值复制到新的数组中,还是很消耗性能的,另外当数组容量满了以后,ArrayList也会扩容,不过这个扩容只会将数组的长度扩大到原先的1.5倍,具体操作时原先的数组长度加上原先长度的右移一位的值(相当于除以2)。

  • LinkedList:
    使用链表实现,也是比较常见的List集合,他同样具有线程不安全,元素的存取有序,数据可重复插入等特点。但是Linked底层使用链表实现,所以他的插入删除比较快,查询就会比较慢,所以他更适合插入删除比较频繁的业务场景。

  • Vector:
    该集合是List集合中的唯一线程安全的集合,元素的存取有序,数据可重复插入,因为List中只有它是线程安全的,因此若是想要线程安全时就必须使用它。但是我们也必须清楚,只要是数组实现那么他的查询就会很快,插入和删除较慢。

  • Set

  • TreeSet:
    所有set都是不安全的,想要使用安全的set必须使用JUC提供的set,同时Set集合具有存取无续性,不可重复等特点,不允许插入null。ThreeSet底层是二叉树,因此元素保存好以后就是按大小排序的,当然这个排序不是天然就支持的需要存入的元素实现Comparable接口重写compareTo方法,或者创建TreeSet时传入一个Comparator接口的对象,这样在数据存放时就会根据实现的比较器来排序。TreeSet底层是TreeMap,因此它的元素不仅需要实现Comparable还需要重写hashcode方法和equals方法。

  • HashSet:
    HashSet同样是不安全的,同样具有存取无序性、元素同样不支持重复,允许插入null,HashSet底层是HashMap,HashMap底层是Hash表+链表+红黑树。一般不使用排序功能都使用HashSet,不排序时HashSet的优于TreeSet。

  • LinkedHashSet:
    该Set线程不安全,元素不可重复,允许插入null,但是他存取有序,若是一定要用Set而又想使存取有序就可以使用LinkedHashSet,它的底层是链表+hash表,链表保证了存取有序,但是却也导致了查询慢,若是没有强制要求不建议使用LinkedHashSet,正常情况下都使用HashSet。

  • Map

  • HashMap:Map不同于List和Set,Map是以键值对的方式来存取数据结构的,Hash具有存取无续,键不可重复,键值均可为null等特点,他的底层是数组+链表+红黑树,无论哪里的面试HashMap都是重中之重,笔者写过一篇专门总结HashMap的文章,该文章总结了HashMap的所有知识场景,有意愿的可以在这里详细了解HashMap。

  • Hashtable:Hashtable线程安全,同样具有存取无续,键不可重复,但是Hashtable键值均不可以为null,Hashtable底层与HashMap基本一致,最大的区别就是方法加了锁操作。

  • TreeMap:底层是红黑树(二叉树的一种),与前面两种一样,存入TreeMap中的键必须实现hashcode和equals方法,值得注意的是TreeMap是TreeSet的底层实现,因此TreeSet具有的特点TreeMap也是拥有的,即:键值均可以为null,且存取无续,但是元素有序,当然元素有序需要传入比较器,或者元素实现Comparable接口。

  • 总结Collection **
    ├——-List 接口:元素按进入先后有序保存,可重复
    │—————-├ LinkedList :允许值重复,存取有序,底层链表,查询慢,增删快,线程不安全,增删效率最高
    │—————-├ ArrayList :允许重复,存取有序,底层数组,查询快,增删慢,线程不安全,查询效率最高
    │—————-└ Vector :允许重复,存取有序,底层数组,查询快,增删慢,线程安全
    │ ———————-└ Stack 是Vector类的实现类
    └——-Set 接口: 仅接收一次,不可重复,并做内部排序
    ├—————-└
    HashSet** :不允许重复,存取无续,元素无续,底层是HashMap,线程不安全
    │————————└ LinkedHashSet :不允许重复,元素无续,底层是LinkedHashMap,线程不安全
    └ —————-TreeSet :不允许重复,底层是TreeMap,元素有序,底层是TreeMap,线程不安全

  • 总结Map
    ├———Hashtable :存取无续,元素无续,不可重复,键值不可为null,线程安全
    ├———HashMap :存取无续,元素无续,不可重复,键值可为null,线程不安全
    │—————–├ LinkedHashMap 双向链表和哈希表实现
    │—————–└ WeakHashMap
    ├ ——–TreeMap :存取无续,元素有序,不可重复,键值可为null,线程不安全,效率低于HashMap
    └———IdentifyHashMap

2.JUC下的集合类

  • CopyOnWrite机制
    这是一种不同于锁机制,不同于CAS,AQS等机制实现的线程安全操作,首先说下CopyOnWrite机制是什么,从字面上就可以理解它就是写入时复制,在多线程环境下若是有多个线程操作同一个数据,若是有线程是对数据进行写操作,那么该线程不能直接修改原数据,而是应该复制出来一个副本,在副本上修改,修改完成后将原引用指向修改后的引用,这样就达成了并发修改的问题,但是若是都只是读的场景,就不会有被访问对象的副本产生,大家都去读一个数据不会有什么并发问题。所以总结下就是写入(增删也是)时复制出一个变量副本,都只操作副本,操作完成后再将原引用指向刚刚操作完成的副本。理解了这个过程就可以很容易看出他的两个弊端:①因为会复制出一个副本,而CopyOnWrite使用的目前只有List和Set,所以很容易造成大对象的多于复制,占用空间,②CopyOnWrite只能保证最终一致性,若是需要实时的数据一致是保证不了的,此外CopyOnWrite中也有使用可冲入锁。
  • CopyOnWriteArrayList
    相当于线程安全的ArrayList,适用于读多写少的场景(优点类似于读写锁的感觉),使用原生的ArrayList在并发时修改会报:ConcurrentModificationException。
  • CopyOnWriteArraySet
    相当于线程安全的HashSet集合,不可重复,可为null,存取无续。
  • Concurrent机制
    下面将要介绍的三种线程的安全的集合都是基于同一种原理实现的,那就是CAS,JDK8以后对CAS又有优化,支持分段CAS+分段自动迁移从而来提升了对并发效率的支持,CAS机制的具体内容可去翻阅本文的第四部分内容,第四大节都在说各种线程安全的实现机制。
  • ConcurrentHashMap
    相当于线程安全的HashMap,使用CAS原理实现的线程安全机制
  • ConcurrentSkipListMap
    使用跳表实现的Map,跳表是一种有序链表,相当于TreeMap
  • ConcurrentSkipListSet
    使用跳表实现的Set,因此有序,相当于线程安全的TreeSet

3.对比两者

因为java提供的集合架构很多不支持并发,所以在JUC包下面才提供了一系列的支持并发的集合,其中包括了List、Set、Map等都有,而且JUC包下的集合,虽然支持并发,却都不是基于synchronized的机制来实现的,他们都是基于CAS、CopyOnWrite机制等来实现的并发安全,这样极大的提高了多线程情况下的效率,所以若是开发多线程,JUC下的集合类则是必须掌握的。