多线程编程

CountDownLatch:

**名字是倒计时锁存器,是一个同步辅助类。 CountDownLatch有一个正数计数器,countDown()方法对计数器做减操作,await()方法等待计数器达到0。所有await的线程都会阻塞直到计数器为0或者等待线程中断或者超时。 **主要用来保证完成某个任务的先决条件满足,它可以让某一个线程等待,直到倒计时结束,再开始执行。

两种典型用法:

1、一个线程在开始运行前等待n个线程执行完毕。将 CountDownLatch 的计数器初始化为n :new CountDownLatch(n),每当一个任务线程执行完毕,就将计数器减1 countdownlatch.countDown(),当计数器的值变为0时,在CountDownLatch上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行

2、 实现多个线程开始执行任务的最大并行性,类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 :new CountDownLatch(1),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为0,多个线程同时被唤醒。

java查看各线程的状态:

在windows系统中可以打开cmd然后进入jdk所在目录,然后执行Jsp,能查看到各线程id,然后执行jstack -F pid就可以看的状态了。

谈谈对 OOM 的认识?如何排查 OOM 的问题?

除了程序计数器,其他内存区域都有 OOM 的风险。

栈一般经常会发生 StackOverflowError,无限创建线程就会发生栈的 OOM

Java 8 常量池移到堆中,溢出会出 java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效;

堆内存溢出,GC 之后无法在堆中申请内存创建对象就会报错;

方法区 OOM,经常会遇到的是动态生成大量的类;

直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。

排查 OOM 的方法:

增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;

使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用 。

同时可以通过jconsole连接正在运行的JVM,实时查看内存使用情况。

常见的oom:

java.lang.OutOfMemoryError: Java heap space:堆溢出

java.lang.OutOfMemoryError: Metaspace:元空间溢出( Metadata 占用空间超限)

java.lang.OutOfMemoryError: GC overhead limit exceeded: GC回收时间过长,时间过多耗费在GC中,但是回收效果不佳。

回收过长指的是超过98%的时间用来做GC,并且回收了不到2%的堆内存

连续多次GC,都只回收了不到2%的极端情况下才会抛出异常,

如不抛出异常,GC清理后的内存也会很快再次填满,迫使GC再次执行,

java.lang.OutOfMemoryError: Direct buffer memory:直接内存溢出( 常见于NIO程序中,使用ByteBuffer来读取和写入数据,这是基于通道channel和缓冲区buffer的IO方式,可以使用Native函数直接分配堆外内存,通过存储在JAVA堆里面的DirectByteBuffer对象作为这块内存的引用进行操作)

java.lang.OutOfMemoryError: unable to create new native thread: 高并发情况下会出现该异常,(原因: 一个应用进程创建太多的线程,超过系统承载极限。如Linux默认允许单个进程可以创建的线程数是1024个。)

并行与并发的区别?

并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。

并行:单位时间内,多个处理器或多核处理器同时处理多个任务。

串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
做一个形象的比喻:顾客买咖啡

并发 = 两个队列和一台咖啡机。

并行 = 两个队列和两台咖啡机。

串行 = 一个队列和一台咖啡机。

进程和线程的区别

进程:

是对运行时程序的封装,是系统进行资源调度和分配的基本单位,实现了操作系统的并发(如:用户运行自己的程序,系统就创建一个进程,并为它分配资源,包括各种表格、内存空间、磁盘空间、I/O设备等,然后该进程被放入到进程的就绪队列,进程调度程序选中它,为它分配CPU及其他相关资源,该进程就被运行起来);

线程:

是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;

区别:

  • 一个程序至少有一个进程,一个进程至少有一个线程,线程依赖于进程而存在;
  • 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存空间。
  • 属于一个进程的所有线程共享该进程的所有资源,不同的进程互相独立。
  • 线程又被称为轻量级进程。进程有进程控制块,线程有线程控制块。但线程控制块比进程控制块小得多。线程间切换代价更小。线程之间的通信比进程之间的通信更加简单。
  • 进程是程序的一次执行,线程可以理解为程序中一段程序片段的执行。

线程和进程的区别?

进程是程序运行和资源分配的基本单位,一个程序至少有一个进程,一个进程至少有一个线程。进程在执行过程中拥有独立的内存单元,而多个线程共享内存资源,减少切换次数,从而效率更高。线程是进程的一个实体,是 cpu 调度和分派的基本单位,是比程序更小的能独立运行的基本单位。同一进程中的多个线程之间可以并发执行。

僵尸进程、孤儿进程

僵尸进程:父进程还在运行但是子进程挂了,但是父进程却没有使用wait来清理子进程的进程信息,导致子进程虽然运行实体已经消失,但是仍然在内核的进程表中占据一条记录,这样长期下去对于系统资源是一个浪费。

僵尸进程的处理:

通过信号机制:子进程退出时向父进程发送信号,父进程处理信号。调用wait()或者waitpid(),让父进程处理完在继续运行。

杀死父进程:强制杀死父进程,僵尸进程会变成孤儿进程,由系统来回收。

重启系统:当系统重启时,所有进程在系统关闭时被停止,包括僵尸进程,开启时init进程会重新加载其他进程。

孤儿进程:

一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1的进程)所收养,并由init进程对它们完成状态收集工作。

但孤儿进程与僵尸进程不同的是,由于父进程已经死亡,系统会帮助父进程回收处理孤儿进程。所以孤儿进程实际上是不占用资源的,因为它要被系统回收了。不会像僵尸进程那样占用ID,损害运行系统。

创建线程的方式:

继承Thread类并重写run方法,调用start执行

public class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println("run task");
    }
}
public class Demo {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

实现Runnable接口,newThread线程,将任务注入到线程中

public class MyThread implements Runnable{
    @Override
    public void run() {
        System.out.println("run task");
    }
}
public class Demo {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread = new Thread(myThread);
        thread.start();
    }
}

通过 Callable 和 Future 创建线程

public class MyThread implements Callable {
    @Override
    public Object call() {
        System.out.println("run!");
        return "run task success";
    }
}
public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyThread myThread = new MyThread();
        FutureTask futureTask = new FutureTask<String>(myThread);
        Thread thread = new Thread(futureTask);
        thread.start();
        // 获得子线程执行结束后的返回值
        System.out.println(futureTask.get()); // run task success
    }

通过线程池来创建线程。

public class Demo01 {
    public static void main(String[] args) {
        //ExecutorService threadPool = Executors.newSingleThreadExecutor(); //创建单个线程
        //ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(5);//创建一个固定大小的线程池
        //ExecutorService threadPool = Executors.newFixedThreadPool(5); //创建一个固定大小的线程池
        ExecutorService threadPool = Executors.newCachedThreadPool(); //创建大小可伸缩的线程池


        try {
            for (int i = 0; i < 30; i++) {
                //使用线程池来创建线程
                threadPool.execute(()->{
                    System.out.println(Thread.currentThread().getName()+"ok");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            threadPool.shutdown(); //线程池使用完毕后需要关闭
        }


    }
}

Runnable和Callable的区别

  1. Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行run()方法中的代码
  2. Callable 接口中的 call() 方法是有返回值的,是一个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果,
  3. Runnable 接⼝不会返回结果或抛出检查异常,但是 Callable 接⼝ 可以。所以,如果任务不需要返回结果或抛出异常推荐使⽤ Runnable 接⼝,这样代码看起来会 更加简洁。

Java实现线程安全的几种方式?

1、通过同步代码块实现线程安全,当多个线程同时访问同步代码块是,只有获得锁资源的线程才能够进行访问,否则就会被阻塞。

2、通过使用原子变量,当多个线程同时更新数据时候可以考虑原子变量,原子变量中提供了大量的线程安全的方法。来保证线程安全,但是值得注意的是,这里的安全是指多个线程同时调用相同的方法,多个线程调用不同的方法也会产生线程安全问题。

3、对于不需要进行更改的变量或者引用变量,可以使用final进行修饰,被final修饰的类变量不能被修改,在类加载的时候就会进行赋值。从而保证了线程安全。

4、在可能产生线程安全问题的地方采用cas的操作,cas是一种乐观锁的思想,认为再修改数据时候没其他线程不会修改当前数据,即使是修改了,自己则进行重试就可以了。

**线程有哪些状态?**线程的生命周期

  • 创建状态:在生成线程对象,并没有调用该对象的start方法,这时线程处于创建状态。

  • 就绪状态**:当调用了线程对象的 start 方法之后,该线程就进入了就绪状态,在线程运行之后,从等待或者睡眠中回来之后,也会处于就绪状态。

  • 运行状态:线程调度程序将处于就绪状态的线程设置为当前线程,此时线程就进入了运行状态,开始运行 run 函数当中的代码。

  • 阻塞状态:线程正在运行的时候,被暂停,通常是为了等待某个事件的发生(比如说某项资源就绪)之后再继续运行。sleep,suspend,wait 等方法都可以导致线程阻塞。

  • 死亡状态:如果一个线程的 run 方法执行结束或者调用 stop 方法后,该线程就会死亡。对于已经死亡的线程,无法再使用 start 方法令其进入就绪。

线程状态图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-utAy9lV8-1659445311217)(C:\Users\Hao\Desktop\面试\img\3cf2a655cfdfac31e345ad642f785da8-272912)]interrupt 打断sleep为异常 打断正常 打断标记为true

yield running 进入timed waiting

sleep running进入runnable

join 谁掉用就是等谁结束

park 打断标记为true park失效

什么是死锁:

在两个或者多个并发进程中,如果每个进程持有某种资源而又等待其它进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。

死锁产生的四个必要条件:(有一个条件不成立,则不会产生死锁)

互斥条件:一 个资源一次只能被一个进程使用 (互斥条件无法破坏)

请求与保持条件:一个进程因请求资源而阻塞时,对已获得资源保持不放

不剥夺条件:进程获得的资源,在未完全使用完之前,不能强行剥夺

循环等待条件:若干进程之间形成一种头尾相接的环形等待资源关系

如何避免死锁

打破请求与保持条件:可以实行资源预先分配策略。使用之前,把所有的资源分配好,自己用自己的,不给别人用,别人也别想用我的。

缺点:

在很多情况下,无法预知一个进程执行前所需的全部资源,因为进程是动态执行的;同时,会降低资源利用率,导致降低了进程的并发性。

**打破不可剥夺条件:**允许进程强剥夺使用其他进程占有的资源。一个进程占有了一部分资源,在其申请新的资源且得不到满足时,它必须释放所有占有的资源以便让其它线程使用。

打破循环等待条件:实行资源有序分配策略,不然它构成闭环。对所有资源排序编号,只有占用了小号资源才能申请大号资源,这样就不回产生环路,预防死锁的发生。

银行家算法: 是一个避免死锁(Deadlock)的著名算法,检查申请者对资源的最大需求量,如果系统现存的各类资源可以满足申请者的请求,就满足申请者的请求。这样申请者就可以很快完成其计算,然后释放它占用的资源,从而保证了系统中所有进程都能完成,所以可避免死锁的发生。

死锁的检测

java中死锁检测手段最多的就是使用JDK带有的jstack和JConsole工具了

1、使用JDK的工具JPS查看运行的进程信息,使用指令jps -l

2、使用jps查看到的进程ID对其进行jstack 进程分析,查看具体的进程,jstack -l 6168

3、Found one Java-level deadlock”,表示程序中发现了一个死锁

利用jconsle也可以进行死锁检测

可以使用jconsole工具,命令行直接输入打开即可,选择要查看的进程,通过检测死锁按钮进行检测。

活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,解决活锁可以错开线程的运行时间,使得一方不能改变另一方的结束条件。

public class TestLiveLock {
    static volatile int count = 10;
    static final Object lock = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            // 期望减到 0 退出循环
            while (count > 0) {
                sleep(0.2);
                count--;
                log.debug("count: {}", count);
            }
        }, "t1").start();
        new Thread(() -> {
            // 期望超过 20 退出循环
            while (count < 20) {
                sleep(0.2);
                count++;
                log.debug("count: {}", count);
            }
        }, "t2").start();
    }
}

Wait 与 Sleep 的区别

  1. Sleep 是 Thread 类的静态方法,Wait 是 Object 的方法,Object 又是所有类的父类,所以所有类都有Wait方法
  2. Sleep 在阻塞的时候不会释放锁,而 Wait 在阻塞的时候会释放锁,它们都会释放 CPU 资源。
  3. Sleep 不需要与 synchronized 一起使用,而 Wait 需要与 synchronized 一起使用(对象被锁以后才能使用)使用
  4. wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。

notify()和 notifyAll()有什么区别?

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只有一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。

  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。

sleep()与yield()

  • sleep()方法暂停当前线程后,会给其他线程执行机会,不区分其他线程的优先级;但yield()方法只会给优先级相同,或优先级更高的线程执行机会。(优先级区分)
  • sleep()方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程被yield()方法暂停之后,立即再次获得处理器资源被执行。(运行状态区分)
  • sleep()方法被打断声明抛出了InterruptedException异常,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常。(抛出异常区分)
  • (不说)sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性。

synchronized 和 Lock 有什么区别?

  • 首先synchronized是java内置关键字,在jvm层面,Lock是个java类;

  • 对于synchronized关键字的代码,执行完会会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁)Lock需在finally中手动调用unlock()方法释放锁(unlock()方法释放锁),否则容易造成线程死锁;

  • 用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;

  • synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可);

  • synchronized只有一个条件变量,lock可以有多个条件变量。

  • lock可以提高多个线程的读操作的效率。

join() 方法

用于等待某个线程结束。哪个线程内调用join()方法,就等待哪个线程结束,然后再去执行其他线程。如在主线程中调用t1.join(),则是主线程等待t1线程结束,join 采用同步。

Thread t1 = new Thread(); 
//等待 t1 线程执行结束 
t1.join(); 
// 最多等待 1000ms,如果 1000ms 内线程执行完毕,则会直接执行下面的语句,不会等够 1000ms 
t1.join(1000);

interrupt() 方法

(不说)interrupt 打断线程有两种情况,如下:

如果一个线程在在运行中被打断,打断标记会被置为 true 。

如果是打断因sleep wait join 方法而被阻塞的线程,会将打断标记置为 false 。

interrupt的本质是将线程的打断标记设为true,打断线程不等于中断线程,有以下两种情况:

  1. 打断正在运行中的线程并不会影响线程的运行,但如果线程监测到了打断标记为true,可以自行决定后续处理。
  2. 打断阻塞中的线程会让此线程产生一个InterruptedException异常,结束线程的运行。但如果该异常被线程捕获住,该线程依然可以自行决定后续处理(终止运行,继续运行,做一些善后工作等等)

常见线程池 —》自定义线程池代码

步骤一:自定义拒绝策略

@FunctionalInterface //拒绝策略
interface RejectPolicy<T>{
    void reject(BlockingQueue<T> queue,T task);
}

步骤2:自定义任务队列

class BlockingQueue<T>{
    //阻塞队列,存放任务
    private Deque<T> queue = new ArrayDeque<>();
    //队列的最大容量
    private int capacity;
    //锁
    private ReentrantLock lock = new ReentrantLock();
    //生产者条件变量
    private Condition fullWaitSet = lock.newCondition();
    //消费者条件变量
    private Condition emptyWaitSet = lock.newCondition();
    //构造方法
    public BlockingQueue(int capacity) {
        this.capacity = capacity;
    }
    //超时阻塞获取
    public T poll(long timeout, TimeUnit unit){
        lock.lock();
        //将时间转换为纳秒
        long nanoTime = unit.toNanos(timeout);
        try{
            while(queue.size() == 0){
                try {
                    //等待超时依旧没有获取,返回null
                    if(nanoTime <= 0){
                        return null;
                    }
                    //该方法返回的是剩余时间
                    nanoTime = emptyWaitSet.awaitNanos(nanoTime);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            T t = queue.pollFirst();
            fullWaitSet.signal();
            return t;
        }finally {
            lock.unlock();
        }
    }
    //阻塞获取
    public T take(){
        lock.lock();
        try{
            while(queue.size() == 0){
                try {
                    emptyWaitSet.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            T t = queue.pollFirst();
            fullWaitSet.signal();
            return t;
        }finally {
            lock.unlock();
        }
    }
    //阻塞添加
    public void put(T t){
        lock.lock();
        try{
            while (queue.size() == capacity){
                try {
                    System.out.println(Thread.currentThread().toString() + "等待加入任务队列:" + t.toString());
                    fullWaitSet.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().toString() + "加入任务队列:" + t.toString());
            queue.addLast(t);
            emptyWaitSet.signal();
        }finally {
            lock.unlock();
        }
    }
    //超时阻塞添加
    public boolean offer(T t,long timeout,TimeUnit timeUnit){
        lock.lock();
        try{
            long nanoTime = timeUnit.toNanos(timeout);
            while (queue.size() == capacity){
                try {
                    if(nanoTime <= 0){
                        System.out.println("等待超时,加入失败:" + t);
                        return false;
                    }
                    System.out.println(Thread.currentThread().toString() + "等待加入任务队列:" + t.toString());
                    nanoTime = fullWaitSet.awaitNanos(nanoTime);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().toString() + "加入任务队列:" + t.toString());
            queue.addLast(t);
            emptyWaitSet.signal();
            return true;
        }finally {
            lock.unlock();
        }
    }
    public int size(){
        lock.lock();
        try{
            return queue.size();
        }finally{
            lock.unlock();
        }
    }
    //从形参接收拒绝策略的put方法
    public void tryPut(RejectPolicy<T> rejectPolicy,T task){
        lock.lock();
        try{
            if(queue.size() == capacity){
                rejectPolicy.reject(this,task);
            }else{
                System.out.println("加入任务队列:" + task);
                queue.addLast(task);
                emptyWaitSet.signal();
            }
        }finally {
            lock.unlock();
        }
    }
}

步骤三:自定义线程池

class ThreadPool{
    //阻塞队列
    BlockingQueue<Runnable> taskQue;
    //线程集合
    HashSet<Worker> workers = new HashSet<>();
    //拒绝策略
    private RejectPolicy<Runnable> rejectPolicy;
    //构造方法
    public ThreadPool(int coreSize,long timeout,TimeUnit timeUnit,int queueCapacity,RejectPolicy<Runnable> rejectPolicy){
        this.coreSize = coreSize;
        this.timeout = timeout;
        this.timeUnit = timeUnit;
        this.rejectPolicy = rejectPolicy;
        taskQue = new BlockingQueue<Runnable>(queueCapacity);
    }
    //线程数
    private int coreSize;
    //任务超时时间
    private long timeout;
    //时间单元
    private TimeUnit timeUnit;
    //线程池的执行方法
    public void execute(Runnable task){
        //当线程数大于等于coreSize的时候,将任务放入阻塞队列
        //当线程数小于coreSize的时候,新建一个Worker放入workers
        //注意workers类不是线程安全的, 需要加锁
        synchronized (workers){
            if(workers.size() >= coreSize){
//                taskQue.put(task);
                //死等
                //带超时等待
                //让调用者放弃执行任务
                //让调用者抛出异常
                //让调用者自己执行任务
                taskQue.tryPut(rejectPolicy,task);
            }else {
                Worker worker = new Worker(task);
                System.out.println(Thread.currentThread().toString() + "新增worker:" + worker + ",task:" + task);
                workers.add(worker);
                worker.start();
            }
        }
    }

    //工作类
    class Worker extends Thread{

        private Runnable task;

        public Worker(Runnable task){
            this.task = task;
        }

        @Override
        public void run() {
            //巧妙的判断,
            //当task部位null直接执行任务
            //当任务完成之后,再接着从任务队列中获取任务并执行
            while(task != null || (task = taskQue.poll(timeout,timeUnit)) != null){
                try{
                    System.out.println(Thread.currentThread().toString() + "正在执行:" + task);
                    task.run();
                }catch (Exception e){

                }finally {
                    task = null;
                }
            }
            synchronized (workers){
                System.out.println(Thread.currentThread().toString() + "worker被移除:" + this.toString());
                workers.remove(this);
            }
        }
    }
}

线程池的优势

  • 降低资源消耗:通过重复利用已创建的线程,从而降低线程创建和销毁造成的消耗。

  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建好之后才能执行,而是利用已经创建好的线程就能立即执行。

  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

  • newSingleThreadExecutor单个线程的线程池,即线程池中每次只有一个线程工作,单线程串行执行任务

  • **newFixedThreadExecutor(n)**固定数量的线程池,每次提交一个任务就是一个线程,直到达到线程池的最大数量,然后后面进入等待队列直到前面的任务完成才继续执行

  • **newCacheThreadExecutor(推荐使用)**可缓存线程池,当线程池大小超过了处理任务所需的线程,那么就会回收部分空闲(一般是60秒无执行)的线程,当有任务来时,又智能的添加新线程来执行。

  • newScheduleThreadExecutor:大小无限制的线程池,支持定时和周期性的执行线程都不会使用以上的线程池,一般自定义线程池

七大参数

  • corePoolSize:表示的是核心线程数量,在默认情况下会一直存活,除非allowCoreThreadTimeOut设置为true就会被超时回收**
  • maximumPoolSize:表示线程所能容许的最大线程数量,当活跃的线程数达到这个值的时候,后序的新任务会被阻塞。**
  • keepAliveTime:是线程超时时长,如果超过这个时长,非核心线程就会被回收。**
  • unit:超时时间的单位
  • workQueue**:任务队列,保存未执行的Runnable 任务,他是使用阻塞队列实现的。
  • threadFactory:创建线程的工厂类,用来指定为线程池创建新线程的方式。**
  • handler:当线程已满,工作队列也满了的时候,就会被调用。被用来实现各种拒绝策略。

四大拒绝策略

  • AbortPolicy(默认):丢弃任务并且抛出异常。
  • DiscardPolicy:默默丢弃任务,不进行任何通知
  • DiscardOldestPolicy:丢弃掉在队列中存在时间最久的任务
  • CallerRunsPolicy:让提交任务的线程去执行任务。

线程池的执行流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZcrANU0M-1659445311220)(C:\Users\Hao\Desktop\面试\img\image-20220715101817694.png)]

线程池工作原理:

ThreadPoolExecutor 使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量, 线程池状态和线程池中线程的数量由一个原子整型ctl来共同表示** *可以通过一次CAS同时更改两个属的值

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0xlVHL7z-1659445311221)(C:\Users\Hao\Desktop\面试\img\image-20220715110302888.png)]

线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。

当线程数达到 corePoolSize时,这时再加入任务,新加的任务会被加入workQueue 队列排 队,直到有空闲的线程。

如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线程来救急。

如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 4 种实现,其它 著名框架也提供了实现

  • 让调用者抛出 RejectedExecutionException 异常,这是默认策略
  • 让调用者运行任务
  • 放弃本次任务放弃队列中最早的任务,本任务取而代之
  • 带超时等待(60s)尝试放入队列
  • 它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

当高峰过去后,超过corePoolSize 的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由 keepAliveTime 和 unit 来控制。

corePoolSize:表示的是核心线程数量,在默认情况下会一直存活,除非allowCoreThreadTimeOut设置为true就会被超时回收maximumPoolSize:表示线程所能容许的最大线程数量,当活跃的线程数达到这个值的时候,后序的新任务会被阻塞。keepAliveTime:是线程超时时长,如果超过这个时长,非核心线程就会被回收。

unit:超时时间的单位

workQueue:任务队列,保存未执行的Runnable 任务,他是使用阻塞队列实现的。

threadFactory:创建线程的工厂类,用来指定为线程池创建新线程的方式。

handler:当线程已满,工作队列也满了的时候,就会被调用。被用来实现各种拒绝策略。

常见的线程池有哪些?

newFixedThreadPool**

核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间

阻塞队列是无界的,可以放任意数量的任务**, new LinkedBlockingQueue()**

评价

适用于任务量已知,相对耗时的任务

newCachedThreadPool

核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着全部都是救急线程(60s 后可以回收)救急线程可以无限创建

队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货),没有线程来取得时候,生产者会进行阻塞。

整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程

适合任务数比较密集,但每个任务执行时间较短的情况

newSingleThreadExecutor

希望多个任务排队执行。线程数固定为 1,任务数多于 1 时,会放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放。

和自己创建单线程执行任务的区别:自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作Executors.newSingleThreadExecutor() 线程个数始终为 1 ,不能修改

FinalizableDelegatedExecutorService 应用的是装饰器模式,只对外暴露了 ExecutorService 接口,因 此不能调用 ThreadPoolExecutor 中特有的方法

和Executors.newFixedThreadPool(1) 初始时为1时的区别:Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改,对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改

创建多少线程池合适

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存

CPU 密集型运算

CPU密集型是指大部分时间的CPU利用率一般都是100%,这时候,通常采用 cpu 核数 + 1 能够实现最优的 CPU 利用率,+1 是保证当线程由于页缺失故障(操作系统)或其它原因 导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费

I/O 密集型运算

CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程 RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率。

经验公式如下

线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间

例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式

4 * 100% * 100% / 50% = 8

例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式

4 * 100% * 100% / 10% = 40

Tomcat 线程池

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jzOd3yxI-1659445311222)(C:\Users\Hao\Desktop\面试\img\image-20220715112250712.png)]

  • LimitLatch 用来限流,可以控制最大连接个数,类似 J.U.C 中的 Semaphore 后面再讲
  • Acceptor 只负责【接收新的 socket 连接】
  • Poller 只负责监听 socket channel 是否有【可读的 I/O 事件】一旦可读,封装一个任务对象(socketProcessor),提交给
  • Executor 线程池处理Executor 线程池中的工作线程最终负责【处理请求】

线程池中 submit()和 execute()方法有什么区别?

  1. execute()只能执行实现Runnable接口类型的任务;而submit()不仅可以执实现Runnable类型接口的任务,也可以执行实现Callable接口类型的任务
  2. execute()没有返回值,而submit()有在添加Callable类型任务的时候有返回值,我们一般通过返回值查看线程执行情况。
  3. 如果线程执行发生异常,submit可以通过Future.get()方法抛出异常,方便我们自定义异常处理;而execute()会终止异常,没有返回值

Runnable和Callable的区别

  1. Runnable 接口中的 run() 方法的返回值是 void,它做的事情只是纯粹地去执行run()方法中的代码

  2. Callable 接口中的 call() 方法是有返回值的,是一个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果,

  3. Runnable 接⼝不会返回结果或抛出检查异常,但是 Callable 接⼝ 可以。所以,如果任务不需要返回结果或抛出异常推荐使⽤ Runnable 接⼝,这样代码看起来会 更加简洁。

手动实现连接池

class Pool {
    // 1. 连接池大小
    private final int poolSize;
    // 2. 连接对象数组
    private Connection[] connections;
    // 3. 连接状态数组 0 表示空闲, 1 表示繁忙
    private AtomicIntegerArray states;
    // 4. 构造方法初始化
    public Pool(int poolSize) {
        this.poolSize = poolSize;
        this.connections = new Connection[poolSize];
        this.states = new AtomicIntegerArray(new int[poolSize]);
        for (int i = 0; i < poolSize; i++) {
            connections[i] = new MockConnection("连接" + (i+1));
        }
    }
    // 5. 借连接
    public Connection borrow() {
        while(true) {
            for (int i = 0; i < poolSize; i++) {
                // 获取空闲连接
                if(states.get(i) == 0) {
                    if (states.compareAndSet(i, 0, 1)) {
                        log.debug("borrow {}", connections[i]);
                        return connections[i];
                    }
                }
            }
            // 如果没有空闲连接,当前线程进入等待,不使用cas的原因是因为连接池的操作较长,利用cas会使得不断尝试获取连接是的cpu浪费,应该进入等待
            synchronized (this) {
                try {
                    log.debug("wait...");
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    // 6. 归还连接
    public void free(Connection conn) {
        for (int i = 0; i < poolSize; i++) {
            if (connections[i] == conn) {
                states.set(i, 0);
                synchronized (this) {
                    log.debug("free {}", conn);
                    this.notifyAll();
                }
                break;
            }
        }
    }
}
class MockConnection implements Connection {
    // 实现略
}


//使用连接池
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
    new Thread(() -> {
        Connection conn = pool.borrow();
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        pool.free(conn);
    }).start();
}

Thread常用方法

start()

启动一个新线程,在新线程中运行 run 方法中的代码start 方法只是让线程进入就绪状态,里面代码不一定立刻运行,只有当 CPU 将时间片分给线程时,才能进入运行状态,执行代码。每个线程的 start 方法只能调用一次,调用多次就会出现 IllegalThreadStateException

join()

等待线程运行结束谁掉用就是等谁结束

setPriority(int)

修改线程优先级java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率

sleep(long n)

让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程

interrupt()

打断当前线程interrupt 打断sleep为异常 打断正常 打断标记为true

Volatile原理

volatile的底层原理是再在底层加入内存屏障:

  • 对volatile变量的写指令加入写屏障
  • 对volatile变量的读指令加入读屏障

volatile保证可见性

当线程要频繁从主内存中读取 数据,JIT 编译器会将数据的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,从而提高效率,但是其他线程对这个值进行修改会导致工作线程不可见。votaile关键字能保证数据读取只从主内存中读,保证了其他线程对数据的修改对当前线程可见。他是通过添加读屏障和写屏障来实现,

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。从而保证了可见性。

volatile保证有序性

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,这种现象叫做指令重排,例如创建对象时一般来说的顺序是先在堆上分配一块内存,然后再初始化对象,最后设置实例指向刚分配的地址,假如不加volatile,可能会发生指令重排序,先分配内存,再分配地址,最后初始化对象,别的线程来发现instance不为空,直接就返回一个未初始化的对象,导致空指针异常。通过votaile关键字添加读写屏障,写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

ThreadLocal的结构

线程局部变量,用于线程之间数据隔离,使得每个线程对于变量的使用互不干扰。比如事务控制中,我们需要对线程中的连接对象设置为手动提交,并且保证连接对象不可被其他线程篡改,所以可以使用ThreadLocal保证一个线程中的连接对象对其他线程不可见。

  • 每个Thread线程内部都有一个ThreadLocalMap,是Thread类的成员变量。
  • Map里面存了Entry对象,key为ThreadLocal本身,而value就是要保存的隔离数据,(User对象), 并且这个Entry对象为弱引用(继承了WeakReference)
  • 对于不同的线程,只能获取当前线程的副本值,形成了副本的隔离。

ThreadLocal 内存泄露问题了解不?

ThreadLocalMap 中使⽤的,entry对象又继承weakreference,并把entry中的key置为软引用。该软引用指向threadlocal,而value 是强引⽤。所以,如果 ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会 被清理掉。这样⼀来, ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远⽆法被 GC 回收,这个时候就可能会产⽣内存泄露。ThreadLocalMap 实现 中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。使⽤完 ThreadLocal ⽅法后 最好⼿动调⽤ remove() ⽅法。

强软弱虚引用

在jdk1.2以前,java的引用定义为:如果引用类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。在jdk1.2之后,java对引用类型进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种。

除强引用外,其他3种引用均可在java.lang.ref包中找到。Rdference子类中只有终结器引用是包内可见的,其他三种均为public,可以在应用程序中直接使用。

a、强引用:在程序代码之中普遍存在的引用赋值,类似 Object obj=new object()这种引用关系。无论任何情况下,只要强引用关系还在,垃圾回收器就永远不会回收掉被引用的对象。(可触及的)特点:可以直接访问对象、不会被回收、可能导致内存泄漏。

b、软引用:用来描述那些有用但是非必要的对象,比如内存敏感缓存。一般情况下,在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出oom异常。(软可触及)常用于实现内存敏感的缓存

Object obj = new object(); //声明强引用
SoftReference<0bject> sf = new SoftReference<0bject>(obj);
obj = null//销毁强引用
sf.get()//获取软引用对象

c、弱引用:描述那些非必须对象,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收器工作时,无论内存是否足够,都会回收掉被弱引用关联的对象。(弱可触及、发现即回收)jdk1.2以后java.lang.ref.WeakReference类实现软引用

String str2 = new String("hello");

ReferenceQueue rQueue = new ReferenceQueue();

java.lang.ref.WeakReference wf = new java.lang.ref.WeakReference(str2, rQueue);

**d、虚引用:**一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来获取一个对象实例。为对象设置虚引用关联的为一作用就是在对象被回收器收集之前收到一个系统通知,用于跟踪垃圾回收过程。虚引用必须和队列一起使用,对象被收集以后,虚引用入队列,以通知应用程序对象的回收情况。

object obj = new object();
ReferenceQueue phantomQueue = new ReferenceQueue( ) ;//队列
PhantomReference<object> pf = new PhantomReference<object>(obj, phantomQueue);
obj = null;
pf.get()//null

线程之间通信:

  • 同步:这里讲的同步是指多个线程通过synchronized关键字这种方式来实现线程间的通信。这种方式,本质上就是“共享内存”式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。

  • while轮询的方式:在这种方式下,线程A不断地改变条件,线程ThreadB不停地通过while语句检测这个条件(例如,list.size==5)是否成立 ,从而实现了线程间的通信。但是这种方式会浪费CPU资源。之所以说它浪费资源,是因为JVM调度器将CPU交给线程B执行时,它没做啥“有用”的工作,只是在不断地测试某个条件是否成立。

  • wait/notify机制:这里用到了Object类的 wait 和 notify 方法。当条件未满足时,线程A调用wait 放弃CPU,并进入阻塞状态。—不像while轮询那样占用CPU当条件满足时,线程B调用 notify通知线程A,所谓通知线程A,就是唤醒线程A,并让它进入可运行状态。这种方式的一个好处就是CPU的利用率提高了。但是也有一些缺点:比如,线程B先执行,调用了notify发送了通知,而此时线程A还执行;当线程A执行并调用wait时,那它永远就不可能被唤醒了。因为,线程B已经发了通知了,以后不再发通知了。这说明:通知过早,会打乱程序的执行逻辑。

  • 管道通信:就是使用java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信,更像消息传递机制,也就是说:通过管道,将一个线程中的消息发送给另一个。

Java 对象头

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cIsMhzTB-1659445311223)(C:\Users\Hao\Desktop\面试\img\image-20220720171523243.png)]

Monitor 原理

每个 java 对象都可以关联一个 Monitor ,如果使用 synchronized 给对象上锁(重量级),那么这个象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MmorkFCV-1659445311223)(C:\Users\Hao\Desktop\面试\img\image-20220715103856470.png)]

  • 刚开始时 Monitor 中的 Owner 为 null
  • 比如当 Thread-2执行 synchronized(obj){} 代码时就会将 Monitor 的所有者Owner 设置为 Thread-2,上锁成功,Monitor 中同一时刻只能有一个 Owner
  • 当 Thread-2 占据锁时,如果线程 Thread-3 ,Thread-4 也来执行synchronized(obj){} 代码,就会进入 EntryList(阻塞队列) 中变成BLOCKED(阻塞)
  • 状态Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • WaitSet 中的 Thread-0,Thread-1 是之前获得过锁。

轻量级锁

如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

1.每次指向到 synchronized 代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以用来储存对象的 Mark Word 和对象引用 reference

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HGamVLS8-1659445311224)(C:\Users\Hao\Desktop\面试\img\image-20220715104136379.png)]

2.在获取锁的时候,让锁记录中的 Object reference 指向对象,并且尝试用 cas(compare and sweep) 替换 Object 对象的 Mark Word ,将 Mark Word 的值存入锁记录中。

3.如果 cas 替换成功,那么对象的对象头储存的就是锁记录的地址和状态 00 表示轻量级锁,如下所示

4.如果cas失败,有两种情况

如果是其它线程已经持有了该这个Object 的轻量级锁,那么表示有竞争,首先会进行自旋锁,自旋一定次数后,如果还是失败就进入锁膨胀阶段。首先会为对象锁申请monitir对象,并将对象锁的对象头设置为monitor的引用地址,设置monitor的owner为已经获得所资源的线程,当前线程则进入entrylist中进行阻塞。

还有就是锁重入的情况,这种情况下 如果是自己的线程已经执行了 synchronized 进行加锁,那么再添加一条 Lock Record 作为重入的计数。并记录所对象的引用。

5.当线程退出 synchronized 代码块的时候,

如果获取的是取值为 null 的锁记录,表示有重入,这时就要重置锁记录,表示重入计数减一

6.当线程退出 synchronized 代码块的时候,

如果获取的锁记录取值为 null,那么说明发生了锁重入,将所记录数减一。

如果获取的锁记录取值不为 null,那么使用 cas 将 Mark Word 的值恢复给对象成功则解锁成功

失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程,将owner置为null,并唤醒entrylist中的线程。如果竞争到当前锁对象,就会进入运行状态。

自选优化;

重量级锁竞争的时候,还可以使用自旋来进行优化,即当线程获取锁资源失败的时候,并不会立即进入堵塞状态,而是进行自选不断尝试重新获取锁,当自选超过一定的次数时,就会进入阻塞状态。如果当前线程自旋成功(即这时候持锁线程已经退出了同步 块,释放了锁),这时当前线程就可以避免阻塞。

偏向锁:

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有, 当有其它线程使用偏向锁对象时,就会撤销偏向锁,会将偏向锁升级为轻量级锁。 当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会批量重偏向,在给这些对象加锁时重新偏向至加锁线程, 当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。就会撤销偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数- XX:BiasedLockingStartupDelay=0来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值

Wait/Notify原理

  1. 锁对象调用wait方法(obj.wait),就会使当前线程进入 WaitSet 中,变为 WAITING 状态。
  2. 处于BLOCKED和 WAITING 状态的线程都为阻塞状态,CPU 都不会分给他们时间片。但是有所区别:
  3. BLOCKED 状态的线程是在竞争对象时,发现 Monitor 的 Owner 已经是别的线程了,此时就会进入 EntryList 中,并处于 BLOCKED 状态。
  4. WAITING 状态的线程是获得了对象的锁,但是自身因为某些原因需要进入阻塞状态时,锁对象调用了 wait 方法而进入了 WaitSet 中,处于 WAITING 状态。
  5. BLOCKED 状态的线程会在锁被释放的时候被唤醒,但是处于 WAITING 状态的线程只有被锁对象调用了 notify 方法(obj.notify/obj.notifyAll),才会被唤醒。但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

撤销偏向:

  • 调用对象的HashCode方法;
  • 多个线程使用这个对象;
  • 调用了wait/notify方法。

MarkWord组成:

  • 无锁标记;
  • 偏向锁标记;
  • 轻量级锁标记;
  • 重量级锁标记;
  • GC信息。

线程的状态流转

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZugGCDvE-1659445311225)(C:\Users\Hao\Desktop\面试\img\image-20220715104816019.png)]

情况一:NEW –> RUNNABLE
当调用了 t.start() 方法时,由 NEW –> RUNNABLE
情况二: RUNNABLE <–> WAITING
当调用了t 线程用 synchronized(obj) 获取了对象锁后,调用 obj.wait() 方法时,t 线程从 RUNNABLE –> WAITING
调用 obj.notify() , obj.notifyAll() , t.interrupt() 时,会在 WaitSet 等待队列中出现锁竞争,非公平竞争
竞争锁成功,t 线程从 WAITING –> RUNNABLE
竞争锁失败,t 线程从 WAITING –> BLOCKED
情况三:RUNNABLE <–> WAITING
当前线程调用 t.join() 方法时,当前线程从 RUNNABLE –> WAITING
注意是当前线程在 t 线程对象的监视器上等待
t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING –> RUNNABLE
情况四: RUNNABLE <–> WAITING
当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE –> WAITING
调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING –> RUNNABLE
情况五: RUNNABLE <–> TIMED_WAITING
t 线程用 synchronized(obj) 获取了对象锁后
调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE –> TIMED_WAITING
t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时
竞争锁成功,t 线程从 TIMED_WAITING –> RUNNABLE
竞争锁失败,t 线程从 TIMED_WAITING –> BLOCKED
情况六:RUNNABLE <–> TIMED_WAITING
当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE –> TIMED_WAITING
注意是当前线程在 t 线程对象的监视器上等待
当前线程等待时间超过了 n 毫秒,或 t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING –> RUNNABLE
情况七:RUNNABLE <–> TIMED_WAITING
当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE –> TIMED_WAITING
当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING –> RUNNABLE
情况八:RUNNABLE <–> TIMED_WAITING
当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线 程从 RUNNABLE –> TIMED_WAITING
调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING–> RUNNABLE
情况九:RUNNABLE <–> BLOCKED
t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE –> BLOCKED
持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争 成功,从 BLOCKED –> RUNNABLE ,其它失败的线程仍然 BLOCKED
情况十: RUNNABLE <–> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED

park和unpark原理:

每个线程都有自己的一个 Parker 对象(由C++编写,java中不可见),由三部分组成 counter, _cond和 _mutex 每次调用park方法时,就会检查count的值,如果为0;则会使线程获得_mutex互斥锁,就进入_cond中进行阻塞,如果为1,则会使count的值设置为0,此时线程并不会进入阻塞状态。

当调用unpark方法时,会设置 _counter 为 1,唤醒 _cond 条件变量中的线程,重新设置 _counter 为 0。线程则会进入runable状态。如果先调用unpark方法,会使得counter的值为1,再次调用park,会让counter为0,,并不会使得线程进入阻塞状态。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hsAGtR0z-1659445311227)(C:\Users\Hao\Desktop\面试\img\image-20220715105214878.png)]

Java 内存模型

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。JMM的意义在于避免程序员直接管理计算机底层内存,用一些关键字synchronized、volatile等可以方便的管理内存。

可见性:其他线程对数据的修改对当前线程可见, 当一个线程频繁读取主内存中的变量值, JIT 编译器会将 变量值值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率,这时当其他线程对主内存中的变量进行修改时,当前线程的变量永远是旧值。

原子性:保证指令不会受到线程上下文切换的影响。多个线程在进行上下文切换的时候,会发生指令交错。这样就不能保证原子性。**

有序性:JVM 会在不影响正确性的前提下,可以调整语句的执行顺序。提高程序的运行效率。现代处理器在一个时钟周期能够完成一条执行时间最长的 CPU 指令。也就是一个时钟周期能够同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 。在一个时钟周期内,同时运行多条指令的不同阶段。通过指令重排,让每一时刻有多条处于不同阶段的指令。提升程序执行效率。在多线程下可能会出现问题。例如创建对象时一般来说的顺序是先在堆上分配一块内存,然后再初始化对象,最后设置实例指向刚分配的地址,假如不加volatile,可能会发生指令重排序,先分配内存,再分配地址,最后初始化对象,别的线程来发现instance不为空,直接就返回一个未初始化的对象,导致空指针异常

1、synchronized关键字

1)线程解锁前,必须把共享变量的最新值刷新到主内存中

2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值

因此,synchronized关键字保证了可见性。synchronized关键字不能阻止指令重排,但是通过加锁的方式保证某一个时刻,只有一个线程访问共享变量,但在一定程度上能保证有序性。因为在单线程的情况下指令重排不影响结果,相当于保障了有序性和原子性。

2、votail关键字

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

保证可见性

对 volatile 变量的写指令后会加入写屏障, 保证在该屏障之前的,对共享变量的改动,都同步到主存当中。对 volatile 变量的读指令前会加入读屏障, 保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

保证有序性

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前,从而保证了有序性。

例如在单例模式下,由于发生了指令重排,当前线程可能获取到并没有初始化完成好的一个实例对象,就会产生异常的发生。

cas乐观重试:

CAS体现的是乐观锁得思想,当进行数据修改时,比较当前变量最新值和之前获取到的值是否一致,如果不一致说明有其他线程对共享变量进行修改,就放弃当前操作,进行重试。 获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。

同样,cas是一种无阻塞并发的一种方式,但是可能产生ABA问题,在某些情况下,ABA问题也可能会导致发生数据安全问题。ABA是指在cas操作过程中,对共享变量进行修改的时候,及时最新的数据和原来获取到的数据一致性,也不能保证没有线程对数据进行修改,比如这样一种情况,一个线程将其修改为其他数据,第二个线程又修改回来,这样造成了ABA,在cas操作中并不能判断是否有其他线程对数据进行修改解决办法:可以使用原子引用 AtomicMarkableReference,给共享变量增加时间戳,通告判断时间戳是否一致来判断是够有其他线程对共享变量的数据进行修改。

为什么无锁效率高

  • 无锁情况下,即使重试失败,线程始终在高速运行,类似于自旋。而 synchronized 会让线程在没有获得锁的时候,进入阻塞。发生上下文切换。线程的上下文切换是费时的,就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火, 等被唤醒又得重新打火、启动、加速… 恢复到高速运行在重试次数不是太多时,无锁的效率高于有锁。
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑 道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还 是会导致上下文切换。所以总的来说,当线程数小于等于cpu核心数时,使用无锁方案是很合适的,因为有足够多的cpu让线程运行。当线程数远多于cpu核心数时,无锁效率相比于有锁就没有太大优势,因为依旧会发生上下文切换。

原子整数:

提供了线程安全的机制,并且大量的使用了CAS的操作提升程序的运行效率

AtomicBoolean

AtomicInteger

AtomicLong

原子引用:

AtomicReference

AtomicMarkableReference

AtomicStampedReference

实际开发的过程中我们使用的不一定是int、long等基本数据类型,也有可能时BigDecimal这样的类型,这时就需要用到原子引用作为容器。

原子数组:

AtomicIntegerArray

AtomicLongArray

AtomicReferenceArray

实际开发的过程中我们使用的不一定是int、long等基本数据类型,也有可能时BigDecimal这样的类型,这时就需要用到原子引用作为容器。

原子累加器

当多个线程对共享变量进行累加操作时候,会设置多个累加单元,不同县城对不同的累加单元进行累加操作,最后将加和返回。累加单元是处在缓存中的,会产生问题。
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中,CPU 要保证缓存数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因 此缓存行可以存下 2 个的 Cell 对象。这样问题来了:
Core-0 要修改 Cell[0]
Core-1 要修改 Cell[1]

无论谁修改成功,都会导致对方 Core 的缓存行失效,比如Core-0 中Cell[0]=6000, Cell[1]=8000要累加Cell[0]=6001, Cell[1]=8000 ,这时会让 Core-1 的缓存行失效
@sun.misc.Contended 用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效

unsafe

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得。jdk8直接调用Unsafe.getUnsafe()获得的。不建议直接使用,会导致不安全的行为发生。

保护性拷贝

在对共享变量进行修改的时候,现将共享变量进行拷贝,在拷贝的共享变量上进行修改操作。修改完成之后,将引用指向修改之后的共享变量。

AQS原理

AQS抽象的队列式同步器,是除了java自带的synchronized关键字之外的锁机制。
AQS的核心思想是,通过维护state变量状态来表示锁资源状态。来如果被请求共享资源空闲(volatile int),就将当前请求资源的线程设置为有效工作线程,并且通过CAS完成state值从0改为1,把共享资源设置为锁定状态,同时。 (state=0表示没有线程占用,state>0表示占用),如果被请求的共享资源被占用,获取不到锁,就把获取不到锁的线程封装成Node节点加入到一个先进先出的队列,进入阻塞状态。这个队列底层使用双向链表实现。
我这里以reentrantLock的非公平锁实现为例子说吧:

加锁过程:

首先使用compareAndSstState(0,1)去修改state的状态,试图从0改到1,如果修改成功,就把owner线程修改为当前线程,第一次操作的时候肯定能成功,比如thread0已经成为了owner,并把state改成了1,如果这时又来一个thread1,他又会去执行加锁的流程,这时候state已经是1了,thread1加锁失败,进入加锁失败的逻辑,这时thread1会再进行一次加锁尝试,如果这时thread0释放了锁,就加锁成功,如果再次失败的话,进入addwaiter逻辑,就会创建一个Node对象,然后把thread1放入节点并加入到等待队列里面去,这个队列是一个双向链表。这个双向链表第一个节点为哑元节点,不关联人和线程,之后的节点才会关联线程。addwiter逻辑执行完之后,这时就会进入AcquireQueue逻辑,会在一个死循环中不断获取锁,这时thread1还是存活的,首先thread1会去判断自己的前驱节点是不是头结点,就是那个哑元,如果是,它又会去尝试获取锁,如果失败,进入shouldParkAfterFailedAcquier逻辑,就会把thread1的前驱节点的那个waitStatus改为-1,(这里的-1表示就是这个节点在需要的时候要去唤醒他的后继的节点),这是thread1还会去尝试一次获取锁,再次失败的话,thread1就在parkAndCheckInterrupt()阻塞,他这里使用的是park方法。只要thread0不释放锁,其他的线程来了也会经历刚才的流程,有顺序地进入队列里面,队列里面的节点除了最后一个的waitStatus是0,前面的都是-1。

释放锁流程:如果没有重入的话,释放的时候会把state设置成0,然后把owner设置成为null,然后会去判断队列的header是不是为null,如果不为空并且header的waitstatus等于-1的话,就会调用unpark方法去唤醒后面那个节点的线程来获取锁(thread1),获取成功以后,第二个节点把他所在的那个节点关联的线程置为空,同时会把前面那个哑节点删除,之前的第二个节点就成为了新的头结点。这就是为什么需要用双端链表。这个时候要是突然右出现一个线程来争抢,有可能第二个几节点的线程会获取锁失败,失败了就继续在队列中等着,这里就是非公平的一个体现吧。

可重入:获取锁的时候会判断是不是当前线层,是的话就是重入了,state的状态就会+1,释放的时候一次-1,直到state为0.

公平锁:公平锁的时候在加锁之前会去判断队列里面有没有其他节点(这里会判断队列里面有没有老二和 老二是不是当前线程),没有才会去获取锁。

条件变量:相当于synchronized的waitset。可以有多个条件变量,每个条件变量对应一个一个等待队列(双链表),实现类是ConditionObject对象。拥有锁的线层调用了ConditionObject的Await后,就会创建Node节点,将当前线程放入Node节点并将状态设置为-2,进入条件变量的队列中去,,然后调用fullRease去释放这个节点关联的线程持有的锁,并唤醒队列里面的节点来竞争锁。

signal流程: 当前持有锁的线程唤醒等待队列中的线程,调用doSignal或doSignalAll方法,如果调用dosingal方法,唤醒等待队列中的线程调用先判断条件变量的队列是否为空,不为空就把第一个加点的线程唤醒,否则则会将Node节点加入到阻塞队列的尾部,并将前驱节点的状态改为-1,将当前节点的状态改为0。如果不为空就去竞争锁。

ReentrantReadWriteLock

当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。但是读写会进入互斥状态。

读写锁的加锁流程与Reentretlock相比没有什么特殊之处, 不同是写锁状态占了 state 的低 16 位,而读锁 使用的是 state 的高 16 位,在加锁时通过判断state的状态来判断占用共享资源的线程加的是读锁还是写锁,如果是加的写锁,那么读锁则会进入阻塞状态,如果是读锁,那么当前线程会让state状态加一。如果读锁进入阻塞,则会把状态设置为share,如果写锁进入阻塞,则会把状态设置为exclued。

举个例子来说:如果一个thread0线程先加写锁,利用 tryAcquire尝试获得写锁,如果成功,用compareAndSstState(0,1)去将state的低16位=1。之后thread1去加读锁,利用acquareShare()加读锁,如果发现state的第16位不为零,加锁失败。如果state低16位为0且高16为不为零,则会将高16位加一,成功获得锁。如果不成功还会执行tryAcquareShare()再次尝试获得锁。如果不成功,就会进入 addWaite()流程.就会创建一个shareNode对象,然后把thread1放入节点并加入到等待队列里面去。这个队列是一个双向链表。这个双向链表第一个节点为哑元节点,不关联人和线程,之后的节点才会关联线程。进入doAcquireShared逻辑,会在一个死循环中不断获取锁,这时thread1还是存活的,首先thread1会去判断自己的前驱节点是不是头结点,就是那个哑元,如果是,它又会去尝试获取锁,如果失败,进入shouldParkAfterFailedAcquier逻辑,就会把thread1的前驱节点的那个waitStatus改为-1,再次失败的话,thread1就在 parkAndCheckInterrupt()阻塞。

解锁时,释放的时候会把state设置成0,然后把owner设置成为null,然后会去判断队列的header是不是为null,如果不为空并且header的waitstatus等于-1的话,就会调用unpark方法去唤醒后面那个节点的线程来获取锁(thread1),如果获取到资源就会在 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点并且在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒。从而实现了读读并发。

semaphore 限制对共享资源的使用

原理:

每当一个线程获得锁资源时,就会将state状态减一,当state状态为0时,其他线程再次获得锁资源就会进入阻塞状态。只有当state状态部位0的时候,才会唤醒阻塞的线程。每当线程释放掉所之后,也会是state状态加一,让其他县城能够获得锁资源,从而保证了同一时间有多个县城能够访问共享资源,但是访问共享资源的数量是有线的。

使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机 线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比 Tomcat LimitLatch 的实现)**

ConcurrentHashMap

ConcurrentHashMap 的实现原理是什么?

JDK1.7中的ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,就是把哈希桶切分成多个Segment片段,每个Segment片段有 n 个 HashEntry 组成。其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry 用于存储键值对数据。
JDK1.8 中的ConcurrentHashMap 选择了Node数组+链表+红黑树结构;在锁的实现上,弃用了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。

并且在1.8中用一个原子变量来维护ConcurrentHashMap的状态,

sizeCtl的含义:(concurrenthashmap)

sizeCtl为0,代表数组未初始化,且数组的初始容量为16

sizeCtl为正数,如果数组未初始化,那么记录的是数组的初始化容量,如果数组已经初始化,那么记录的是数组的扩容阈值(数组的初始化容量*0.75)

sizeCtl为-1,表示数组正在进行初始化

sizeCtl小于0,并且不是-1,-(n+1)代表有n个线程正在完成数组扩容

ConcurrentHashMap 的 put 方法执行逻辑是什么?

JDK1.7通过segementShift和segmentMask和key值的哈希码来确定在哪一个Segments数组上,

然后尝试获取锁,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁。

获取到锁后:

  • 通过 key 的 hashcode来确认在Segment的哪一个HashEntry上。
  • 遍历该 HashEntry,如果不为空则判断传入的 key 和遍历到的 key 是否相等,相等则覆盖旧的 value。
  • 不相等则需要新建一个 HashEntry 并加入到 链表中,同时会先判断是否需要扩容。如果需要扩容,则会先扩容在添加新的HashEntry。
  • 释放 Segment 的锁。

JDK1.8

大致可以分为以下步骤:

  1. 首先还是根据 key 计算出 hash值。通过hash值确定应该放入那一个节点上,

  2. 然后判断是不是需要进行初始化,因为数组为懒惰初始化的,因为这时要是没有进行过put操作的话,数组长度还是0,就要先初始化数组。

  3. 定位到 Node,拿到首节点 f,判断首节点 f:

    如果为 null ,则通过cas的方式尝试添加新的节点 ,填入key和value。

    不为null,如果头节点的hash值为MOVE(ForwardingNode),表示正在扩容和迁移。那么当前线程就会帮忙扩容。

    如果都不满足 ,synchronized 锁住 f 节点,判断头结点hash>0表示是链表,否则还是红黑树,遍历插入。

当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。

ConcurrentHashMap的size方法执行逻辑是什么?

1.7中:

java7中的HashMap中进行size的操作,首先是在不加锁的状态下进行计算两次的size如果一致,则进行返回,如果不一致进行重试,阈值为3次。如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回

1.8中:

没有竞争发生,向 baseCount 累加计数有竞争发生,通过累加数组,新建 counterCells,向其中的一个 cell 累加计counterCells 初始有两个 cell如果计数竞争比较激烈,会创建新的 cell 来累加计数

JDK1.7与JDK1.8 中ConcurrentHashMap 的区别?

数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

保证线程安全机制:JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。

锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

hashtable、ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?

ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m已经被别得线程改了,可能已经不同了,无法检验,会出现二义性。

初始化,因为数组为懒惰初始化的,因为这时要是没有进行过put操作的话,数组长度还是0,就要先初始化数组。

  1. 定位到 Node,拿到首节点 f,判断首节点 f:

    如果为 null ,则通过cas的方式尝试添加新的节点 ,填入key和value。

    不为null,如果头节点的hash值为MOVE(ForwardingNode),表示正在扩容和迁移。那么当前线程就会帮忙扩容。

    如果都不满足 ,synchronized 锁住 f 节点,判断头结点hash>0表示是链表,否则还是红黑树,遍历插入。

当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。

ConcurrentHashMap的size方法执行逻辑是什么?

1.7中:

java7中的HashMap中进行size的操作,首先是在不加锁的状态下进行计算两次的size如果一致,则进行返回,如果不一致进行重试,阈值为3次。如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回

1.8中:

没有竞争发生,向 baseCount 累加计数有竞争发生,通过累加数组,新建 counterCells,向其中的一个 cell 累加计counterCells 初始有两个 cell如果计数竞争比较激烈,会创建新的 cell 来累加计数

JDK1.7与JDK1.8 中ConcurrentHashMap 的区别?

数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

保证线程安全机制:JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。

锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

hashtable、ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?

ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m已经被别得线程改了,可能已经不同了,无法检验,会出现二义性。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值