面试准备之并发进阶

简介: 面试准备之并发进阶

说一说自己对于 synchronized 关键字的了解


synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。


另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。


说说自己是怎么使用 synchronized 关键字,在项目中用到了吗


synchronized 关键字最主要有以下 3 种应用方式,下面分别介绍

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。


总结: synchronized 关键字加到静态方法和 synchronized(class)代码块上都是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!


面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”


双重校验锁实现对象单例(线程安全)


public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}
复制代码


另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。


uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址


但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。


例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。


使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行


讲一下 synchronized 关键字的底层原理


synchronized 关键字底层原理属于 JVM 层面。


Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。


推荐阅读:深入理解Java并发之synchronized实现原理


1、synchronized 同步语句块的情况


public class SynchronizedDemo {
    public void method(){
        synchronized (this){
            System.out.println("synchronized code");
        }
    }
}
复制代码


通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -verbose SynchronizedDemo.class


image.png


从上面我们可以看出:


synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。


2、synchronized 修饰方法的的情况


public class SynchronizedDemo {
    public synchronized void foo(){
        System.out.println("synchronized method");
    }
}
复制代码


image.png


synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。


说说 JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗


JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。


锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。


推荐阅读:不可不说的Java“锁”事


谈谈 synchronized和ReentrantLock 的区别


1、两者都是可重入锁


两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。


2、synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API


synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。


3、ReentrantLock 比 synchronized 增加了一些高级功能


相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。


如果你想使用上述功能,那么选择ReentrantLock是一个不错的选择。


4、性能已不是选择标准


Java内存模型


在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。


image.png


要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。


说白了, volatile 关键字的主要作用就是保证变量的可见性,然后还有一个作用是防止指令重排序


image.png


推荐阅读:Java内存模型(JMM)总结


说说 synchronized 关键字和 volatile 关键字的区别


  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。synchronized 关键字在 JavaSE1.6 之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
  • 多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。


说一下Lock锁的底层原理


synchronized 依赖于 JVM 而 Lock 依赖于 API,我们研究 Lock 锁的底层原理,通过学习 Lock 锁的实现类  ReentrantLock


ReentrantLock 锁基于 AQS(AbstractQueuedSynchronizer)来实现的。 其中的 Node 节点通过双向链表( 用于存储等待中的线程)进行存储,被 volatile 所修饰,int 类型的 state 变量来表示同步状态。获取锁时,AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改,如果当场没获取到,会将该线程放在线程等待链表中。释放锁时,修改状态值,调整等待链表。


推荐阅读:深入浅出Java锁--Lock实现原理(底层实现)


说说 synchronized 关键字和 Lock 类的区别


image.png


ThreadLocal 是什么?有哪些使用场景?


它是线程的局部变量,属于线程自身所有,不在多个线程间共享。ThreadLocal 定义的通常是与线程关联的私有静态字段(例如,用户ID或事务ID)。


  1. 使用 ThreadLocal 可以代替一些参数的显式传递。
  2. 比如用来存储用户 Session。Session 的特性很适合 ThreadLocal ,因为 Session 只在当前会话周期内有效,会话结束便销毁。
  3. 在一些多线程的情况下,如果用线程同步的方式,当并发比较高的时候会影响性能,可以改为 ThreadLocal 的方式,例如高性能序列化框架 Kyro 就要用 ThreadLocal 来保证高性能和线程安全;
  4. 线程内上下文管理器、数据库连接等可以用到 ThreadLocal;


ThreadLocal示例


import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalDemo implements Runnable {
    // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMDD HHmm"));
    @Override
    public void run() {
        System.out.println("Thread Name = "+Thread.currentThread().getName()+" default form atter = "+formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        formatter.set(new SimpleDateFormat());
        System.out.println("Thread Name = "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
    }
    public static void main(String[] args) throws InterruptedException {
        ThreadLocalDemo obj = new ThreadLocalDemo();
        for(int i=0;i<10;i++){
            Thread thread = new Thread(obj,""+i);
            Thread.sleep(new Random().nextInt(1000));
            thread.start();
        }
    }
}
复制代码


执行结果为:


Thread Name = 0 default form atter = yyyyMMDD HHmm
Thread Name = 1 default form atter = yyyyMMDD HHmm
Thread Name = 0 formatter = yy-M-d ah:mm
Thread Name = 1 formatter = yy-M-d ah:mm
Thread Name = 2 default form atter = yyyyMMDD HHmm
Thread Name = 3 default form atter = yyyyMMDD HHmm
Thread Name = 2 formatter = yy-M-d ah:mm
Thread Name = 4 default form atter = yyyyMMDD HHmm
Thread Name = 5 default form atter = yyyyMMDD HHmm
Thread Name = 3 formatter = yy-M-d ah:mm
Thread Name = 5 formatter = yy-M-d ah:mm
Thread Name = 6 default form atter = yyyyMMDD HHmm
Thread Name = 4 formatter = yy-M-d ah:mm
Thread Name = 7 default form atter = yyyyMMDD HHmm
Thread Name = 8 default form atter = yyyyMMDD HHmm
Thread Name = 7 formatter = yy-M-d ah:mm
Thread Name = 6 formatter = yy-M-d ah:mm
Thread Name = 8 formatter = yy-M-d ah:mm
Thread Name = 9 default form atter = yyyyMMDD HHmm
Thread Name = 9 formatter = yy-M-d ah:mm
复制代码


从输出中可以看出,Thread-0 已经改变了 formatter 的值,但仍然是 Thread-2 默认格式化程序与初始化值相同,其他线程也一样。


ThreadLocal原理


从 Thread类源代码入手。


public class Thread implements Runnable {
 ......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
 ......
}
复制代码


从上面 Thread 类源代码可以看出 Thread 类中有一个 threadLocals 和 一个inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法。

ThreadLocal类的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);
    }
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
复制代码


通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。


每个 Thread 中都具备一个 ThreadLocalMap,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key 的键值对。这里解释了为什么每个线程访问同一个 ThreadLocal,得到的确是不同的数值。另外,ThreadLocal 是 map 结构是为了让每个线程可以关联多个 ThreadLocal 变量。


ThreadLocalMap 是 ThreadLocal 的静态内部类。


image.png


ThreadLocal 内存泄露问题


实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉。


所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap 中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value,在线程对象结束前,会发生内存泄漏。( 比如使用线程池的时候,线程结束是不会销毁的,会再次使用的就可能出现内存泄露 。 )


ThreadLocalMap 实现中已经考虑了这种情况,在调用get()、 set()、remove() 方法的时候,会清理掉 key 为 null 的记录。如果说会出现内存泄漏,那只有在出现了 key 为 null 的记录后,没有手动调用 remove() 方法,并且之后也不再调用 get()、 set()、remove() 方法的情况下。


推荐阅读:深入浅出ThreadLocal

ThreadLocal内存泄漏问题


为什么要用线程池?


借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处:

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。


创建线程池有哪几种方式?


①. newFixedThreadPool(int nThreads)

创建一个固定长度的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,当线程发生未预期的错误而结束时,线程池会补充一个新的线程。


②. newCachedThreadPool()

创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的大小依赖于操作系统。


③. newSingleThreadExecutor()

这是一个单线程的Executor,它创建单个工作线程来执行任务,如果这个线程异常结束,会创建一个新的来替代它;它的特点是能确保依照任务在队列中的顺序来串行执行。


④. newScheduledThreadPool(int corePoolSize)

创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。


public class ThreadPool {
    public static class HelloRunnable implements Runnable{
        @Override
        public void run() {
            String name = Thread.currentThread().getName();
            System.out.println(name+"----it's runnable");
        }
    }
    ExecutorService es = Executors.newCachedThreadPool();
    public void getThreadPool2(){
        for(int i=0;i<12;i++){
            HelloRunnable thread = new HelloRunnable();
            es.execute(thread);
        }
        es.shutdown();
    }
    ExecutorService es3 = Executors.newFixedThreadPool(2);
    public void getThreadPool3(){
        for(int i=0;i<12;i++) {
            HelloRunnable thread = new HelloRunnable();
            es3.execute(thread);
        }
        es3.shutdown();
    }
    ExecutorService es4 = Executors.newSingleThreadExecutor();
    public void getThreadPool4(){
        for(int i=0;i<12;i++) {
            HelloRunnable thread = new HelloRunnable();
            es4.execute(thread);
        }
        es4.shutdown();
    }
    ScheduledExecutorService es5 = Executors.newScheduledThreadPool(2);
    public void getThreadPool5(){
        HelloRunnable thread = new HelloRunnable();
        //参数1:目标对象   参数2:隔多长时间开始执行线程,    参数3:执行周期       参数4:时间单位
        es5.scheduleAtFixedRate(thread, 0, 100, TimeUnit.MICROSECONDS);
    }
}
复制代码


线程池都有哪些状态?


线程池有 5 种状态:Running、ShutDown、Stop、Tidying、Terminated。


1.RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。线程池的初始化状态是 RUNNING。线程池被一旦被创建,就处于 RUNNING 状态,并且线程池中的任务数为0。


2.SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。调用线程池的 shutdown()方法时,线程池由 RUNNING -> SHUTDOWN。


3.STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。调用线程池的 shutdownNow()方法时,线程池由(RUNNING or SHUTDOWN ) -> STOP。


4.TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。因为 terminated()在 ThreadPoolExecutor 类中是空的,所以用户想在线程池变为 TIDYING 时进行相应的处理;可以通过重载 terminated()函数来实现。


当线程池在 SHUTDOWN 状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。

当线程池在 STOP 状态下,线程池中执行的任务为空时,就会由 STOP -> TIDYING。


5.TERMINATED:线程池处在 TIDYING 状态时,执行完 terminated()之后,就会由 TIDYING -> TERMINATED。


image.png


ThreadPoolExecutor七大参数


通过查看上述三大方法的源码,可以发现都是 new 了一个 ThreadPoolExecutor 对象,只是传入的参数有所不同,关于 ThreadPoolExecutor 的构造方法定义如下:


public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}
复制代码


参数理解:


  • corePoolSize:核心线程数。在创建了线程池后,线程中没有任何线程,等到有任务到来时才创建线程去执行任务。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建  一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中。
  • maximumPoolSize:最大线程数。表明线程中最多能够创建的线程数量,此值必须大于等于1。
  • keepAliveTime:空闲的线程保留的时间
  • unit:空闲线程的保留时间单位
  • BlockingQueue:阻塞队列,存储等待执行的任务。参数有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue可选。
  • ThreadFactory:线程工厂,用来创建线程,一般默认即可
  • RejectedExecutionHandler:队列已满,而且任务量大于最大线程的异常处理策略。有以下取值


ThreadPoolExecutor.AbortPolicy  //丢弃任务并抛出RejectedExecutionException异常
ThreadPoolExecutor.CallerRunsPolicy //由调用线程处理该任务
ThreadPoolExecutor.DiscardOldestPolicy  //丢弃队列最前面的任务,然后重新尝试执行任务
ThreadPoolExecutor.DiscardPolicy  //也是丢弃任务,但是不抛出异常。
复制代码


ThreadPoolExecutor底层原理


image.png


我们用个案例来进行解析:银行办理业务。比如说目前银行只有两个工作窗口对外开放,有三个空闲位置允许等待,当一下子来了5个人,其中两个人去办理业务,另外三个人去等待。如果人数大于5,则临时开放另外三个工作窗口来处理业务办理。那么该银行最多一次可以接收8个人,其中5个人办理业务,另外3个人等候。


流程图如下:


image.png


  1. 在创建了线程池后,开始等待请求。


  1. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:
  1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务:
  2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列:
  3. 如果这个时候队列满了且正在运行的线程数量还小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
  4. 如果队列满了且正在运行的线程数量大 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。


  1. 当一个线程完成任务时,它会从队列中取下一个任务来执行。


  1. 当一个线程无事可做超过一定的时间(keepA1iveTime)时,线程会判断:
  • 如果当前运行的线程数大于 corePoolSize ,那么这个线程就被停掉。
  • 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。


代码实现为:


public class PoolDemo02 {
    static final int NUM = 8;
    public static void main(String[] args) {
        ExecutorService executorService = new ThreadPoolExecutor(2, 5, 3, TimeUnit.SECONDS, new LinkedBlockingDeque<>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());//该拒绝策略会抛出异常信息
        try {
            //最大承载:Deque+max=3+5
            for (int i = 1; i <= NUM; i++) {
                executorService.execute(() -> {
                    System.out.println(Thread.currentThread().getName());
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
    }
}
复制代码


当 NUM 值不大于5时,执行结果为:


pool-1-thread-2
pool-1-thread-1
pool-1-thread-2
pool-1-thread-1
pool-1-thread-2
复制代码


当 NUM 值大于5,不大于8时,执行结果为:


pool-1-thread-1
pool-1-thread-2
pool-1-thread-1
pool-1-thread-2
pool-1-thread-3
pool-1-thread-1
pool-1-thread-4
pool-1-thread-5
复制代码


当 NUM 值大于8时,执行会报错,错误信息如下:


java.util.concurrent.RejectedExecutionException
复制代码


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


  • 接收的参数列表不一样
  • submit 有返回值,而 execute 没有
  • submit 方便 Exception 处理


在 java 程序中怎么保证多线程的运行安全?


  1. 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);
  2. 可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);
  3. 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。


介绍一下 Atomic 原子类


所谓原子类说简单点就是具有原子/原子操作特征的类。


并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示。


image.png


JUC 包中的原子类是哪 4 类?


基本类型


使用原子的方式更新基本类型

  • AtomicInteger:整形原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类


数组类型


使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray:引用类型数组原子类


引用类型


  • AtomicReference:引用类型原子类
  • AtomicStampedRerence:原子更新引用类型里的字段原子类
  • AtomicMarkableReference :原子更新带有标记位的引用类型


对象的属性修改类型


  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。


讲讲 AtomicInteger 的使用


AtomicInteger 类常用方法


public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为 newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
复制代码


AtomicInteger 类的使用示例


使用 AtomicInteger 之后,不用对 increment() 方法加锁也可以保证线程安全。


class AtomicIntegerTest {
        private AtomicInteger count = new AtomicInteger();
      //使用 AtomicInteger 之后,不需要对该方法加锁,也可以实现线程安全。
        public void increment() {
                  count.incrementAndGet();
        }
       public int getCount() {
                return count.get();
        }
}
复制代码


介绍一下 AtomicInteger 类的原理


AtomicInteger 线程安全原理简单分析

AtomicInteger 类的部分源码:


// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    private volatile int value;
复制代码


AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。


CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。


AQS 介绍


AQS 的全称为(AbstractQueuedSynchronizer),这个类在 java.util.concurrent.locks 包下面。


image.png


AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。


AQS 原理概览


AQS中 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。


这里volatile能够保证多线程下的可见性,当state=1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个FIFO的等待队列中,比如会被UNSAFE.park()操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。


另外state的操作都是通过CAS来保证其并发修改的安全性,有公平和非公平两者模式来唤醒等待的线程。


看个 AQS(AbstractQueuedSynchronizer) 原理图:


image.png


AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。


private volatile int state;//共享变量,使用 volatile 修饰保证线程可见性
复制代码


状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作


//返回同步状态的当前值
protected final int getState() {  
        return state;
}
 // 设置同步状态的值
protected final void setState(int newState) { 
        state = newState;
}
//原子地(CAS 操作)将同步状态值设置为给定值 update 如果当前同步状态的值等于 expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
复制代码


推荐阅读:深入浅出java同步器AQS


AQS 对资源的共享方式


AQS 定义两种资源共享方式


  • Exclusive(独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
    公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如 Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。
目录
相关文章
|
4月前
|
并行计算 数据挖掘 大数据
[go 面试] 并行与并发的区别及应用场景解析
[go 面试] 并行与并发的区别及应用场景解析
|
1月前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
2月前
|
Java 调度 Android开发
Android面试题之Kotlin中async 和 await实现并发的原理和面试总结
本文首发于公众号“AntDream”,详细解析了Kotlin协程中`async`与`await`的原理及其非阻塞特性,并提供了相关面试题及答案。协程作为轻量级线程,由Kotlin运行时库管理,`async`用于启动协程并返回`Deferred`对象,`await`则用于等待该对象完成并获取结果。文章还探讨了协程与传统线程的区别,并展示了如何取消协程任务及正确释放资源。
41 0
|
4月前
|
Java 程序员 调度
面试准备-并发
面试准备-并发
|
4月前
|
消息中间件 Java 中间件
复盘女朋友面试4个月的并发面试题
该文章主要复盘了关于并发的面试题,包括线程池的使用场景、原理、参数合理化设置,以及ThreadLocal、volatile、synchronized关键字的使用场景和原理,还介绍了juc并发工具包中aqs的原理,强调在面试中要将自己理解的点与面试官讲透。
复盘女朋友面试4个月的并发面试题
|
4月前
|
安全 Go 调度
[go 面试] 深入理解并发控制:掌握锁的精髓
[go 面试] 深入理解并发控制:掌握锁的精髓
|
4月前
|
JavaScript 前端开发 Java
面试官:假如有几十个请求,如何去控制并发?
面试官:假如有几十个请求,如何去控制并发?
|
4月前
|
算法 Go 数据库
[go 面试] 并发与数据一致性:事务的保障
[go 面试] 并发与数据一致性:事务的保障
|
5月前
|
设计模式 安全 Java
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
89 1
|
4月前
|
NoSQL Go API
[go 面试] 为并发加锁:保障数据一致性(分布式锁)
[go 面试] 为并发加锁:保障数据一致性(分布式锁)