Java 多线程

EmiyaCC 于 2021-06-24 发布

Java 内存模型(JMM)

Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

JMM

简述线程的可见性

可见性指当一个线程修改了共享变量时,其他线程能够立即得知修改。volatile,synchronized,final都能保证可见性

volatile 可以保证线程安全吗

不能绝对保证

可以使用 volatile 的条件:

  1. 对变量的写操作不依赖当前值。如 i++ 是无法通过volatile保证结果准确性的;

    i++使用volatile线程不安全

  2. 该变量没有包含在具有其它变量的不变式中

    // 若同时设置了setLower(8)和setUpper(5),就会设置无效范围(8, 5)
    public class NumberRange {
        private volatile int lower = 0;
         private volatile int upper = 10;
       
        public int getLower() { return lower; }
        public int getUpper() { return upper; }
       
        public void setLower(int value) { 
            if (value > upper) 
                throw new IllegalArgumentException(...);
            lower = value;
        }
       
        public void setUpper(int value) { 
            if (value < lower) 
                throw new IllegalArgumentException(...);
            upper = value;
        }
    }
    

并行和并发有什么区别?

所以并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。

线程和进程的区别?

进程时操作系统进行资源分配的最小单元;线程时操作系统进行任务分配的最小单元,线程隶属于进程

进程在执行过程中拥有独立的内存单元,而多个线程共享内存资源,减少切换次数,从而效率更高。同一进程中的多个线程之间可以并发执行。

进程间通信 IPC 方式

概念:每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

方式:

  1. 管道:内核中申请一块固定大小的缓冲区,无名管道一般使用fork函数实现父子进程的通信,命名管道用于没有血缘关系的进程也可以进程间通信;面向字节流、自带同步互斥机制、半双工,单向通信,两个管道实现双向通信。
  2. 消息队列:在内核中创建一队列,队列中每个元素是一个数据报,不同的进程可以通过句柄去访问这个队列;消息队列独立于发送与接收进程,可以通过顺序和消息类型读取,也可以fifo读取;消息队列可实现双向通信
  3. 共享内存:将同一块物理内存一块映射到不同的进程的虚拟地址空间中,实现不同进程间对同一资源的共享。目前最快的IPC形式,不用从用户态到内核态的频繁切换和拷贝数据,直接从内存中读取就可以,共享内存是临界资源,所以需要操作时必须要保证原子性。使用信号量或者互斥锁都可以。
  4. 信号量: 在内核中创建一个信号量集合(本质是个数组),数组的元素(信号量)都是1,使用P操作进行-1,使用V操作+1,通过对临界资源进行保护实现多进程的同步
  5. 套接字:socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据。socket起源于UNIX,在Unix一切皆文件哲学的思想下,socket是一种”打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个”文件”,在建立连接打开后,可以向自己文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。是一种可以网间通信的方式。
  • https://www.jianshu.com/p/c1015f5ffa74
  • https://www.sohu.com/a/430956382_701814

CPU上下文切换

什么是 CPU 上下文:

CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。

什么是 CPU 上下文切换:

把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

一文让你明白CPU上下文切换- 知乎

进程调度算法

为什么需要调度算法

用户进程数一般都多于处理机数,从而导致进程互相争夺处理机。同时,系统进程也需要使用处理机。

因此,这就需要进程调度程序按一定的策略,动态地把处理机分配给处于就绪队列中的某一个进程,使之执行。

分类

  1. 抢占式
  2. 非抢占式

调度算法

http://jalan.space/2019/03/16/2019/process-scheduling/

Linux 默认的进程调度方法

  1. CFS完全公平调度: CFS的出发点基于一个简单的理念:即所有进程实际占用处理器CPU的时间应为一致,目的是确保每个进程公平的处理器使用比,即最大的利用了计算资源。
  2. FIFO先入先出队列:不基于时间片调度,处于可运行状态的SCHED_FIFO级别的进程比SCHED_NORMAL有更高优先级得到调度,一旦SCHED_FIFO级别的进程处于可执行的状态,它就会一致运行,直到进程阻塞或者主动释放。
  3. RR(Round-Robin):SCHED_RR级别的进程在耗尽事先分配的时间片之后就不会继续执行。即可以理解将RR调度理解为带有时间片的SCHED_FIFO。

https://zhuanlan.zhihu.com/p/75879578

守护线程是什么?

守护线程(即daemon thread),是个服务线程,准确地来说就是服务其他的线程。

创建线程有哪几种方式?

  1. 继承Thread类重写run方法

    public class Main {
        public static void main(String[] args) {
            Thread t = new MyThread();
            t.start();
        }
    }
    class MyThread extends Thread {
        @Overrride
        public void run() {
            System.out.println("Start new thread");
        }
    }
    
  2. 通过Runnable接口重写run方法

    public class Main {
        public static void main(String[] args) {
            Thread t = new Thread(new MyRunnable());
            t.start();
        }
    }
    class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("Start new thread");
        }
    }
    
  3. 通过Callable和Future创建线程

    1. 实现 Callable 接口,重写 call 方法
    2. 创建 Callable 类的实例,使用 FutureTask 类对象包装该实例,FutueTask 对象封装了 Callable 对象 call 方法的返回值
    3. 将 FutureTask 作为 Thread 对象的 target 属性传入,并启动线程。
    4. 调用 FutureTask 对象的 get 方法,获取返回值。
    public class Main { 
        public static void main(String[] args) { 
            FutureTask futureTask = new FutureTask<>(new MyCallable()); 
            Thread thread = new Thread(futureTask); 
            thread.start();
            try { 
                Thread.sleep(1000);
                System.out.println("返回的结果是:" + futureTask.get()); 
            } catch (Exception e) {
                e.printStackTrace();
            } 
        }
    }
    class MyCallable implements Callable { 
        @Override
        public Integer call() throws Exception {
            System.out.println(Thread.currentThread().getName()); 
            return 99; 
        }
    }
    
  4. 通过线程池创建线程

    public class SingleThreadExecutorTest { 
        public static void main(String[] args) { 
            ExecutorService executorService = Executors.newSingleThreadExecutor(); 
            MyRunnable myRunnable = new MyRunnable(); 
            for(int i = 0; i < 10; i++){ 
                executorService.execute(myRunnable); 
            } 
            System.out.println("=======任务开始======="); 
            executorService.shutdown(); 
        }
    }
    class MyRunnable implements Runnable{ 
        @Override 
        public void run() {
            System.out.println(Thread.currentThread().getName()); 
        }
    }
    // 根据阿里开发手册,不推荐使用 executors 创建线程池,直接使用 threadPoolExecutor 创建,自己定义 队列的类型和长度(默认情况为 Integer.MAX_VALUE,)
    

线程有哪些状态?

线程通常都有 6 种状态,new, runnable, terminated, timed waiting, waiting, blocked。

sleep() 和 wait() 有什么区别?

sleep():方法是线程类(Thread)的静态方法,让调用线程进入睡眠状态,让出执行机会给其他线程,等到休眠时间结束后,线程进入就绪状态和其他线程一起竞争cpu的执行时间。因为sleep() 是static静态的方法,他不能改变对象的机锁,当一个synchronized块中调用了sleep() 方法,线程虽然进入休眠,但是对象的机锁没有被释放,其他线程依然无法访问这个对象。

wait():wait()是Object类的方法,当一个线程执行到wait方法时,它就进入到一个和该对象相关的等待池,同时释放对象的机锁,使得其他线程能够访问,可以通过notify,notifyAll方法来唤醒等待的线程

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

线程的 run()和 start()有什么区别?

每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。

start()方法来启动一个线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码; 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

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

  1. newFixedThreadPool(int nThreads)

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

  2. newCachedThreadPool()

    创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。

  3. newSingleThreadExecutor()

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

  4. newScheduledThreadPool(int corePoolSize)

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

线程池都有哪些状态?

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

线程池各个状态切换框架图:

线程池状态

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

线程池的优点

  1. 采用线程池的方案,将线程重复利用,从而保证系统的效率,避免过多资源浪费在创建销毁线程上。
  2. 提高相应速度,请求或者任务到达可以直接相应处理
  3. 将任务提交和执行分离,降低耦合
  4. 提高线程的可管理性。使用线程池统一分配,调优,监控。

设计一个线程池的思路

线程池的生命周期:

  1. 启动线程池时,我们需要预先创建并启动若干个线程以用来接收传入的任务
  2. 创建的线程名称,优先级等待属性需要统一设置,我们可以通过一个特定或默认的工厂进行批量生产
  3. 启动线程后,我们还要对线程进行重复利用,那么需要容器来存取线程
  4. 启动的线程数必须要有限制,不然无尽的线程数会使cpu频繁切换上下文,从而使cpu资源严重浪费,同时大量的线程也会占用大量的内存空间,导致 OOM
  5. 如果传入新任务,但所有线程都在执行任务中,无暇顾及传入的任务。需要将其缓存下来,等待任务完毕的某个线程接收该任务继续工作
  6. 继续传入新任务,导致超出缓存大小,或者缓存过大占用大量内存空间进而 OOM。那么可以增加若干个线程,加快处理任务的进度
  7. 如果还是不停的有任务加入,但是依照现在的资源状况,既不允许将任务缓存,也不能允许增加线程进行处理。就只好寻找一个策略来处理这些的任务
  8. 任务逐渐处理完毕,不需要新创建的线程就能够应对了,可以将这些线程销毁来降低资源的消耗。不过考虑到万一销毁后马上又有大批新任务处理不过来,于是设置一个空闲等待的销毁时间,这段时间里这些线程还是空闲,则销毁,反之则继续运行
  9. 如果运行过程中,需要将线程池停下来,要么所有线程马上停止,要么正在运行的线程运行完毕后停止
  10. 所有任务全部都处理完毕了,所有线程都处于空闲状态了,需要将所有线程封存起来,以尽力降低空闲线程的消耗

线程池中比较重要的一些属性和方法:

  1. preStartAllCoreThreads() 方法预热线程池,不断向线程池中加入新的worker线程,直到达到 核心线程数 corePoolSize
  2. 线程工厂 threadFactory ,批量创建线程,定制线程的名称。
  3. 工作线程池 workers,使用 HashSet 用于存取 work 线程。
  4. 核心线程数 corePoolSize,维持线程最基本的线程数,注意尽量不要设置数值很大,如 Integer.MAX_VALUE。不然创建大量线程导致CPU和内存资源过于紧张浪费。
  5. 工作队列 workQueue,是一个阻塞队列,保存等待执行的任务。有很多类,但是注意尽量不要使用无界队列 (界限最大值为Integer.MAX_VALUE),任务入队过多会导致内存紧张进而 OOM,并且会使 maximumPoolSize失效
  6. 最大线程数 maximumPoolSize,用于在阻塞队列满了后,继续创建新线程执行任务。同理不得设置过大
  7. 拒绝策略、饱和策略 handler,在线程池和队列都满的情况下,需要采取一种策略处理新提交的任务。Java 提供的策略有:
    • AbortPolicy :直接抛异常
    • DiscardPolicy:丢弃任务,但是不抛出异常。
    • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
    • CallerRunPolicy:当线程池无能力处理当前任务时,会将这个任务的执行权交予提交任务的线程来执行,也就是谁提交谁负责,这样的话提交的任务就不会被丢弃而造成业务损失,同时这种谁提交谁负责的策略必须让提交线程来负责执行,如果任务比较耗时,那么这段时间内提交任务的线程也会处于忙碌状态而无法继续提交任务,这样也就减缓了任务的提交速度,这相当于一个负反馈。也有利于线程池中的线程来消化任务
  8. 空闲线程存活时间 keepAliveTime,单位是 unit,在线程空闲后如果超过这个时间就将其销毁。
  9. shutDownshutDownNow 方法

Java 线程池

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

并发三大特性:

线程安全在三个方面体现:

多线程锁的升级原理是什么?

在Java中,锁共有4种状态,级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。

锁升级的图示过程:

锁升级1

锁升级2

什么是死锁?

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

怎么防止死锁?

死锁的四个必要条件:

方法:

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

线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

ThreadLocal 是线程共享变量。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是 ThreadLocal 对象,值是 Entry 对象,ThreadLocalMap是每个线程私有的

存在的问题

  1. 对于线程池,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用, 造成一系列问题。
  2. 内存泄漏。由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放,产生内存泄漏。

使用场景:最常见的 ThreadLocal 使用场景为用来解决数据库连接、Session 管理等。

说一下 synchronized 底层实现原理?

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

深入分析Synchronized原理(阿里面试题)

synchronized 和 volatile 的区别是什么?

synchronized 和 Lock 有什么区别?

synchronized 和 ReentrantLock 的异同?

Reference:

  • https://mp.weixin.qq.com/s/ywOwdXuMG5rhEXMH_OXKEQ