Java八股文背诵 第三天 java多线程

Java 多线程

  1. 进程和线程的区别

    • 进程是系统运行程序的基本单位,在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
    • 线程是进程中的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的内存空间和系统资源。线程是操作系统调度的最小单位,它负责执行进程中的任务,但是线程的并发执行也可能导致一些问题,如竞态条件、死锁等。每个线程共享进程的堆和方法区(JDK 1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
    • 总结:线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
  2. Java 创建线程有哪几种方式

    • 继承自 Thread
      • 创建一个类,继承 Thread 类,重写 run() 方法。该方法包含线程的执行代码。创建类的实例并调用 start() 方法来启动线程。
      • 示例代码:
        class MyThread extends Thread {
            public void run() {
                // 线程执行的逻辑
                System.out.println("MyThread is running...");
            }
        }
        // 创建并启动线程
        MyThread myThread = new MyThread();
        myThread.start();
        
    • 实现 Runnable 接口
      • 创建一个类,实现 Runnable 接口,实现 run() 方法。创建一个 Thread 对象,将实现类的实例作为参数传递给 Thread 构造函数,调用 start() 方法来启动线程。
      • 示例代码:
        class MyRunnable implements Runnable {
            public void run() {
                // 线程执行的逻辑
                System.out.println("MyRunnable is running...");
            }
        }
        // 创建并启动线程
        Thread myThread = new Thread(new MyRunnable());
        myThread.start();
        
    • 使用 Executor 框架
      • Executor 框架是 Java 并发编程中的高级工具,它提供一种更为灵活的方式来管理和执行线程。通过 Executor 可以将任务提交给线程池,由线程池来管理线程的生命周期和执行。
      • 示例代码:
        import java.util.concurrent.Executor;
        import java.util.concurrent.Executors;
        
        class MyTask implements Runnable {
            public void run() {
                // 线程执行的逻辑
                System.out.println("MyTask is running...");
            }
        }
        
        // 创建线程池并提交任务
        Executor executor = Executors.newFixedThreadPool(3);
        executor.execute(new MyTask());
        
    • 使用 CallableFuture
      • 创建一个实现 java.util.concurrent.Callable 接口的类,实现 call() 方法,该方法可以返回结果。使用 java.util.concurrent.Future 接口来获取线程执行的结果。
      • 示例代码:
        class MyCallable implements Callable<Integer> {
            public Integer call() throws Exception {
                return 42;
            }
        }
        
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<Integer> future = executor.submit(new MyCallable());
        
        try {
            Integer result = future.get(); // 获取线程执行结果
        } catch (InterruptedException | ExecutionException e) {
            // 处理异常
        }
        
  3. 线程 startrun 的区别

    • run 方法是线程的执行体,包含线程要执行的代码。当直接调用 run 方法时,它会在当前线程的上下文中执行,而不会创建新的线程。
    • start 方法用于启动一个新的线程,并在新线程中执行 run 方法的代码。调用 start 方法会为线程分配系统资源,并将线程置于就绪状态,当调度器选择该线程时,会执行 run 方法中的代码。
    • 因此,虽然可以直接调用 run 方法,但这并不会创建一个新的线程,而是在当前线程中执行 run 方法的代码。如果需要实现多线程执行,则应该调用 start 方法来启动新线程。
  4. 你知道 Java 中有哪些锁吗

    • 公平锁和非公平锁
      • 公平锁:是指多个线程按照申请锁的顺序来获取锁。
      • 非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能会造成优先级反转或者饥饿现象。
      • 对于 Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
    • 可重入锁
      • 可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。也就是说,同一个线程可以多次获取同一个锁。
      • 对于 ReentrantLock 而言,它是一个可重入锁。对于 synchronized 而言,它也是一个可重入锁。可重入锁的一个好处是可以一定程度避免死锁。
      • 示例代码:
        synchronized void setA() throws Exception {
            Thread.sleep(1000);
            setB();
        }
        
        synchronized void setB() throws Exception {
            Thread.sleep(1000);
        }
        
    • 独占锁和共享锁
      • 独占锁:是指该锁一次只能被一个线程所持有。
      • 共享锁:是指该锁可被多个线程所持有。
      • 对于 Java ReentrantLock 而言,它是独占锁。但是对于 Lock 的另一个实现类 ReadWriteLock 而言,其读锁是共享锁,其写锁是独占锁。
    • 互斥锁和读写锁
      • 上面讲的独占锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
      • 对于 synchronized 而言,它是一个互斥锁。
      • 对于 ReentrantLock 而言,它是一个独占锁。对于 ReadWriteLock 而言,它是一个读写锁。
    • 乐观锁和悲观锁
      • 乐观锁:是指在多线程并发操作时,认为数据在大多数情况下不会出现并发冲突,所以在操作数据时不加锁,而是通过在操作完成时检查数据是否被其他线程修改过,如果没有被修改过则操作成功,否则操作失败。乐观锁通常使用版本号(version)或时间戳(timestamp)来实现。
      • 悲观锁:是指在多线程并发操作时,认为数据在大多数情况下会出现并发冲突,所以在操作数据时会先加锁,以防止其他线程对数据进行修改。悲观锁通常使用锁机制来实现。
      • 乐观锁和悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
      • 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
      • 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新、不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
      • 从上面的描述我们可以看出,悲观锁适合写操作非常频繁的场景,乐观锁适合读操作非常频繁的场景,不加锁会带来大量的性能提升。
      • 悲观锁在 Java 中的使用,就是利用各种锁。
      • 乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子类,通过 CAS 自旋实现原子操作的更新。
    • 分段锁
      • 分段锁其实是一种锁的设计,并不是具体的一种锁。对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
      • 我们以 ConcurrentHashMap 来说一下分段锁的含义以及设计思想。ConcurrentHashMap 中的分段锁称为 Segment,它既类似于 ReentrantLock,又是一个中 HashMap 的实现的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表。当线程 put 的时候,会先根据 key 的 hash 值找到对应的 Segment,然后对该 Segment 进行加锁,所以当多个线程 put 的时候,只要不是放在同一个 Segment 中,就可以实现真正的并行的插入。
      • 分段锁的设计的目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一个项进行加锁操作。
    • 偏向锁、轻量级锁、重量级锁
      • 这三种锁是指锁的状态,并且是针对 synchronized。在 Java 5 通过引入锁升级的机制来实现高效同步。synchronized 这种锁的状态是通过对象监视器在对象头中的字段来表明的。
      • 偏向锁:是指当一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
      • 轻量级锁:是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁。其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
      • 重量级锁:是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
    • 自旋锁
      • 在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。这样可以减少线程上下文切换的消耗。
      • 缺点是循环会消耗 CPU。
  5. Java 线程安全的实现

    • 阻塞同步
      • 也就是使用锁实现,具体采用什么锁,有两种选择:
        • 内置锁也就是 synchronized 关键字。
        • JUC 下具体锁的实现。
    • 非阻塞同步
      • 使用锁带来的主要问题,是频繁的线程阻塞、唤醒操作以及用户态内核态的切换带来的性能问题。
      • 可能这些额外的操作带来的时间消耗远大于线程自身的业务执行时间。所以引入非阻塞同步,也就是基于 CAS 操作。
      • 最直接的实现就是 JUC 下的各种原子类的实现。虽然 CAS 避免了锁带来的性能开销,不过其仅适用于少部分同步场景,没有阻塞同步更加具有普适性。
      • 缺点:
        • 未获取同步资源的线程陷入自旋状态,所以对于 CPU 的消耗很高。
        • 仅能操作单个共享资源,对于组合类型还是需要加锁处理,或者重新组合为一个共享资源。
        • ABA 问题。
    • 无同步方案
      • 线程的本地存储,主要是用于对于一个共享资源都尽可能的在同一个线程中执行。
      • 使用场景:
        • 譬如生产者 - 消费者模型中,消费者消费消费一个内容。
        • web 服务中,一个请求对应一个服务线程,也属于生产者 - 消费者模型。
        • 链路跟踪中动态采集方法的执行信息。
  6. 说一说你对 synchronized 的理解

    • 在 Java 中,synchronized 是一种关键字,用于实现线程同步。
    • synchronized 等待锁时是不能被 Thread.interrupt() 中断的,因此程序设计时必须检查确保合理,否则可能会造成线程死锁的尴尬境地。
    • 尽管 Java 实现的锁机制有很多种,并且有些锁机制性能也比 synchronized 高,但还是强烈推荐在多线程应用程序中使用该关键字,因为实现方便,后续工作由 JVM 来完成,可靠性高。只有在确定锁机制是当前多线程程序的性能瓶颈时,才考虑使用其他机制,如 ReentrantLock 等。
    • 当一个方法或代码块被 synchronized 修饰时,它将成为一个临界区,同一时刻只能由一个线程访问。其他线程必须等待当前线程退出临界区才能进入。确保多个线程在访问共享资源时不会产生冲突。
    • synchronized 可以应用于方法或代码块。当它应用于方法时,整个方法被锁定;当它应用于代码块时,只有该代码块被锁定。这样做的好处是,可以选择性地锁定对象的一部分,而不是整个方法。
    • synchronized 实现的机理依赖于软件层面上的 JVM,因此其性能会随着 Java 版本的不断升级而提高。到了 Java 1.6,synchronized 引入了自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的 Java 1.7 与 1.8 中,均对该关键字的实现机理做了优化。
    • 示例代码:
      public synchronized void synchronizedMethod() {
          // 在这个方法内部的代码被锁定,同一时间只有一个线程能够执行它
      }
      
      public void someMethod() {
          synchronized (this) {
              // 这个代码块被锁定,同一时间只有一个线程能够执行它
          }
      }
      
  7. synchronizedlock 的区别是什么

    • ReentrantLockjava.util.concurrent.locks 包下 Lock 接口的一个具体实现。
    • 机制
      • synchronized:是 Java 语言内置的同步机制,基于 JVM 的内置锁实现。
      • lock:是 Java 提供的显式锁机制,需要手动获取和释放锁。
    • 灵活性
      • synchronized:灵活性相对较低,只能用于方法或代码块。
      • lock:提供了更多的灵活性,例如可以尝试获取锁、可中断获取锁、可设置锁的公平性等。
    • 等待与通知
      • synchronized:与 wait()notify() / notifyAll() 方法一起使用,用于线程的等待和通知。
      • lock:可以与 Condition 接口结合,实现更细粒度的线程等待和通知机制。
    • 可重入性
      • synchronizedReentrantLock 都是可重入的,即同一个线程可以多次获取同一个锁。
    • 公平性
      • synchronized 的锁是非公平的,即锁的获取顺序是不确定的。
      • ReentrantLock 支持公平锁和非公平锁,可以根据需要设置。
    • 中断响应
      • synchronized 不支持响应中断,线程在等待锁时无法响应中断。
      • ReentrantLock 支持响应中断,线程在等待锁时可以响应中断。
    • 锁绑定条件
      • synchronized 不支持绑定多个条件。
      • ReentrantLock 可以与多个 Condition 对象结合,实现更复杂的线程同步机制。
    • 性能
      • 在某些场景下,ReentrantLock 可能通过提供更细粒度的控制和灵活性,实现比 synchronized 更高的性能。但这也取决于具体的使用场景和编程实践。
  8. volatile 关键字的作用有哪些

    • 可见性:是指当一个线程访问同一个变量时,一个线程修改这个变量的值,其他线程能够立即看到这个修改的值。
    • 实现原理
      • 要保证不同线程对该变量操作的内存可见性,volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证每次读写变量都从主内存中读,跳过 CPU cache 这一步。当一个线程修改这个变量的值,新值对于其他线程是立即得知的。
      • 禁止指令重排序:指令重排序是 JVM 为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。volatile 变量禁止指令重排序。针对 volatile 修饰的变量,在读写操作指令前后会插入内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前。
    • 使用场景
      • 用于修饰变量,确保变量的可见性和禁止指令重排序。
      • 示例代码:
        volatile int count = 0;
        
  9. volatilesynchronized 的对比

    • 机制和用途
      • synchronized:它是 Java 的一个关键字,用于提供线程间的同步机制。当一个线程进入一个由 synchronized 修饰的代码块或方法时,它会获取一个监视器锁(monitor lock),这保证同一时间只有一个线程可以执行这段代码。其主要用途是确保数据的一致性和线程安全性。
      • volatile:这是 Java 的一个关键字,用于修饰变量。volatile 的主要作用是确保变量的可见性,即当一个线程修改一个 volatile 变量的值,其他线程能够立即看到这个修改。此外,它还可以防止指令重排序。但是,volatile 并不能保证复合操作的原子性。
    • 原子性
      • synchronized:它可以保证被其修饰的代码块的原子性,即这段代码在执行过程中不会被其他线程打断。
      • volatile:只能保证单个读写操作的原子性,对于复合操作(如自增、自减等)则无法保证原子性。
    • 互斥性
      • synchronized:提供互斥性,即同一时间只有一个线程可以执行被其修饰的代码块或方法。
      • volatile:不提供互斥性,只是确保变量的可见性。
    • 性能
      • volatile 性能肯定比 synchronized 关键字要好,因为它不涉及锁的获取和释放。但是,这也意味着它提供的同步级别较低。
    • 使用范围
      • synchronized 关键字可以修饰方法以及代码块。
      • volatile 关键字只能用于变量。
  10. 为什么要有线程池,线程太多会怎样

    • 资源管理:在多线程应用中,每个线程都需要占用内存和 CPU 资源。如果不加限制地创建线程,会导致系统资源耗尽,可能引发系统崩溃。线程池通过限制并控制线程的数量,帮助避免这个问题。
    • 提高性能:线程的创建和销毁开销相对较大。线程池可以复用线程,避免频繁地创建和销毁线程,从而提高系统性能。
    • 任务排队:线程池可以用于排队任务,确保任务按照一定的顺序执行,避免线程竞争和冲突。
    • 避免线程过多:如果不使用线程池,程序员可能会手动创建大量线程来处理任务,这会导致线程过多,消耗过多的系统资源,甚至降低性能。线程池通过限制线程数量来避免这种情况。
    • 总结:采用多线程编程的时候如果线程过多会造成系统资源的大量占用,降低系统效率。如果有些线程存活的时间很短但是又不得不创建很多这种线程也会造成资源的浪费。线程池的作用就是创造并且管理一部分线程,当系统需要处理任务时直接将任务添加到线程池的任务队列中,由线程池决定由哪个空闲且存活线程来处理,当线程池中线程不够时会适当创建一部分线程,线程冗余时会销毁一部分线程。这样提高线程的利用率,降低系统资源的消耗。
    • 示例代码
      import java.util.concurrent.ExecutorService;
      import java.util.concurrent.Executors;
      
      public class ResourceManagementExample {
          public static void main(String[] args) {
              // 创建一个固定大小的线程池,包含 3 个线程
              ExecutorService executor = Executors.newFixedThreadPool(3);
      
              // 提交任务给线程池
              for (int i = 0; i < 5; i++) {
                  final int taskId = i;
                  executor.submit(() -> {
                      System.out.println("Task " + taskId + " is running on thread " + Thread.currentThread().getName());
                  });
              }
      
              // 关闭线程池
              executor.shutdown();
          }
      }
      
  11. 说一说线程池的常用参数

    • corePoolSize:核心线程数。线程池中长期存活的线程数。
    • maximumPoolSize:最大线程数。线程池允许创建的最大线程数量,当线程池的任务队列满了之后,可以创建的最大线程数。
    • keepAliveTime:空闲线程存活时间。当线程池中没有任务时,会销毁一些线程,销毁的线程数 = 最大线程数 - 核心线程数。
    • TimeUnit:时间单位。空闲线程存活时间的描述单位。配合参数 3 一起使用,用于描述参数 3 的时间单位。
    • BlockingQueue:线程池任务队列。线程池存放任务的队列,用来存储线程池的所有待执行任务。
    • ThreadFactory:创建线程的工厂。线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等。
    • RejectedExecutionHandler:拒绝策略。当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略。
  12. BIO、NIO、AIO 的区别

    • 概念
      • BIO(Blocking I/O):BIO 指的是同步阻塞进行 I/O 操作。当执行到 I/O 操作的代码时,线程会一直阻塞(无论是在等待操作还是进行读写操作),直到 I/O 操作完成以后才会执行后续的代码。如果有大量的并发连接,会导致大量线程阻塞,造成资源浪费。
      • NIO(Non-blocking I/O):引入了 ChannelBuffer 的概念。Channel 是双向的,可以读也可以写,而 Buffer 是负责传输的缓冲区。使用 NIO 时线程不再阻塞了,而是采用轮询的方式进行 I/O 操作,即一个线程可以进行多个 I/O 操作,可以利用 while 循环,然后在里面不断地询问多个 I/O 是否准备好了,某一个 I/O 如果准备好了就进行 I/O 操作,如果没有就会询问下一个 I/O。
      • AIO(Asynchronous I/O):异步非阻塞模型。AIO 是在 NIO 的基础上进一步发展的,使用 AIO 时,该线程开启另一个线程,将 I/O 操作交给另一个线程执行,然后执行后续的代码,当另一个线程执行完 I/O 操作以后会通知该线程,通常使用采用回调函数的方式来执行 AIO。
    • 使用场景
      • BIO:对于连接数较小且固定的情况,BIO 是可以接受的,但在高并发的环境中,性能可能会受到限制。
      • NIO:适用于连接数较多且连接时间较短的场景,能够通过单一线程管理多个连接,提高了系统的扩展性和并发性。
      • AIO:适用于处理大量并发连接,且连接时间较长的场景,能够在 I/O 操作完成后异步通知应用程序。
      • 总的来说,BIO 适用于连接数较小的情况,NIO 适用于连接数较多且连接时间较短的情况,AIO 适用于连接数较多且连接时间较长的情况。选择合适的模型取决于具体的应用场景和需求。在实际开发中,NIO 比 BIO 更常用,而 AIO 的应用相对较少。
    • 举例:类比烧开水
      • BIO:相当于在烧一壶开水,我一直在这一壶水面前等待水烧开,只有水烧开了我才能去做别的事情。
      • NIO:相当于我烧一排开水,我不断地检查有没有开水烧开,等到所有开水烧开我才能去做别的事情。
      • AIO:相当于我把开水交给另一个人去烧开,然后我去做别的事情了,当水烧开了这个人就会过来通知我水已经烧开了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值