面试系列(四):多线程

565 阅读31分钟

创建线程的方式有哪些?

Java线程的创建方式主要分为以下四类,每种方式在实现原理、适用场景及优缺点上存在显著差异:

一、继承 Thread 类

  1. 核心原理
    通过继承 Thread 类并重写 run() 方法定义线程逻辑,调用 start() 启动线程(非直接调用 run(),否则退化为普通方法调用)14。

  2. 代码示例

    javaCopy Code
    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("子线程执行");
        }
    }
    public class Main {
        public static void main(String[] args) {
            new MyThread().start(); // 启动线程
        }
    }
    
  3. 优缺点

    • 优点‌:简单直观,适合快速实现单线程任务29。

    • 缺点‌:

      • Java单继承限制,无法复用已有类继承结构27;
      • 任务与线程强耦合,复用性差9。

二、实现 Runnable 接口

  1. 核心原理
    实现 Runnable 接口的 run() 方法,将任务逻辑与线程对象解耦,通过 Thread 构造函数传入实例启动线程16。

  2. 代码示例

    javaCopy Code
    class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("Runnable线程执行");
        }
    }
    public class Main {
        public static void main(String[] args) {
            new Thread(new MyRunnable()).start(); // 通过Thread启动
        }
    }
    
  3. 优缺点

    • 优点‌:

      • 支持多接口实现,灵活性高69;
      • 任务可复用,适合线程池管理79。
    • 缺点‌:需依赖 Thread 类包装9。

三、实现 Callable 接口

  1. 核心原理
    实现 Callable 接口的 call() 方法(支持返回值和抛出异常),通过 FutureTask 包装后由 Thread 启动58。

  2. 代码示例

    javaCopy Code
    class MyCallable implements Callable<String> {
        @Override
        public String call() throws Exception {
            return "Callable执行结果";
        }
    }
    public class Main {
        public static void main(String[] args) throws Exception {
            FutureTask<String> task = new FutureTask<>(new MyCallable());
            new Thread(task).start();
            System.out.println(task.get()); // 获取返回值
        }
    }
    
  3. 优缺点

    • 优点‌:支持异步获取结果和异常处理,适合复杂任务59。
    • 缺点‌:实现复杂度较高89。

四、线程池(ExecutorService)

  1. 核心原理
    通过 Executors 工具类或 ThreadPoolExecutor 创建线程池,提交 Runnable/Callable 任务执行14。

  2. 代码示例

    javaCopy Code
    public class Main {
        public static void main(String[] args) {
            ExecutorService pool = Executors.newFixedThreadPool(3);
            pool.execute(() -> System.out.println("线程池任务执行"));
            pool.shutdown();
        }
    }
    
  3. 优缺点

    • 优点‌:

      • 降低线程创建销毁开销16;
      • 支持任务队列和拒绝策略,资源可控6。
    • 缺点‌:需合理配置参数避免资源耗尽6。

五、其他衍生方式

  1. 匿名内部类/Lambda
    简化代码,适用于临时任务(本质仍为 Runnable 或 Thread)35。
  2. 守护线程
    通过 setDaemon(true) 设置,随主线程结束而终止(如垃圾回收线程)

java线程的生命周期

  1. NEW(新建)

    • 线程被创建但未调用start()

    • 示例:

      javaCopy Code
      Thread t = new Thread();  // 状态为NEW
      
  2. RUNNABLE(可运行)

    • 调用start()后进入该状态,包括:

      • READY‌:等待CPU分配时间片
      • RUNNING‌:正在执行(JVM内部区分,对外统一为RUNNABLE)
  3. BLOCKED(阻塞)

    • 线程尝试获取‌同步锁‌失败时进入(如synchronized竞争)
    • 与WAITING的区别‌:BLOCKED是主动争抢锁失败,WAITING是被动等待唤醒
  4. WAITING(无限等待)

    • 调用以下方法进入:

      • Object.wait()
      • Thread.join()
      • LockSupport.park()
    • 需其他线程显式唤醒(如notify()

  5. TIMED_WAITING(超时等待)

    • 带超时参数的等待:

      javaCopy Code
      Thread.sleep(1000);        // 休眠
      obj.wait(500);             // 限时等待
      Thread.join(2000);         // 超时合并
      LockSupport.parkNanos(1e9);// 定时阻塞
      
  6. TERMINATED(终止)

    • run()方法执行完毕或抛出未捕获异常

java线程池的哪几个参数比较重要,如何配置比较好?

Java线程池的参数决定了线程池的行为,以下是几个比较重要的参数:

  1. corePoolSize:线程池的核心线程数。当线程池中的线程数小于corePoolSize时,新的任务会被立即创建一个新线程来执行,即使池中其他线程处于空闲状态。如果线程数已达到corePoolSize,新的任务将被放入任务队列中等待执行。
  2. maximumPoolSize:线程池的最大线程数。当任务队列已满且池中线程数小于maximumPoolSize时,新的任务会创建新的线程来执行。如果线程数已达到maximumPoolSize,则新的任务将被拒绝。
  3. keepAliveTime:当线程池中的线程数大于corePoolSize时,超出部分线程的最长空闲时间。当空闲时间超过keepAliveTime时,线程将被终止。
  4. queueCapacity:任务队列的容量。当任务数大于corePoolSize时,新的任务将被放入队列中等待执行。如果队列已满,则新的任务将被拒绝,或者如果线程池最大线程数还大于核心线程数,就会创建新的线程。
  5. RejectedExecutionHandler:线程池中任务拒绝策略。当任务队列已满且线程数已达到maximumPoolSize时,新的任务将被拒绝。可以通过指定RejectedExecutionHandler来处理被拒绝的任务。
  6. workQueue:任务队列。可以设置的有四种,ArrayBlockingQueue(有界队列,固定容量,任务超出时触发扩容或拒绝策略),LinkedBlockingQueue(默认无界队列(容量Integer.MAX_VALUE),易导致OOM;可设容量转为有界'),SynchronousQueue(无缓冲队列,任务直接交付线程执行(需立刻有可用线程)),PriorityBlockingQueue(优先级队列,按任务优先级排序执行

如何配置线程池:

  1. 根据业务需求和服务器硬件情况来设置线程池的核心线程数和最大线程数。一般情况下,核心线程数应该设置为服务器CPU核数的2倍左右,最大线程数应该根据业务负载和服务器硬件来进行调整。
  2. 根据业务的特点和性能需求,设置任务队列的容量。任务队列应该尽可能地大,但不能无限制地增大,以避免内存溢出。
  3. 根据业务负载和服务器硬件,设置线程空闲时间。如果服务器负载较高,线程空闲时间可以适当缩短;如果服务器负载较低,线程空闲时间可以适当延长,以减少线程创建和销毁的开销。
  4. 根据业务需求和服务器硬件情况,选择合适的RejectedExecutionHandler。如果任务数量较大,可以使用ThreadPoolExecutor.CallerRunsPolicy(由提交任务的线程直接执行该任务)来执行被拒绝的任务,即使用调用者线程来执行任务。如果任务数量不是很大,可以使用ThreadPoolExecutor.AbortPolicy(抛出RejectedExecutionException异常)来拒绝任务。

Java.until.concurrent包里面的类,请大概讲解一下。

这个java sdk提供的并发包,主要的类第一个首先是原子类,比如说AutomicLong,AutomicInteger,AutomicBoolean,还有AutomicReference类,原子类的主要作用,主要是为了在无锁环境下,保持并发安全。原子类之所以能保持并发安全的前提下还能性能高,主要原因是底层是使用的CAS,比较并交换,说白了,就是在给一个变量赋值的时候,要判断这个变量的值是否还是符合我们预期的,如果符合,那么就给这个变量赋新值,这个比较类似于我们数据库中乐观锁。

除了原子类以外,还有实现管程Lock类和Condition类,Lock类我们经常用来实现互斥,而Condition类我们经常用来实现同步。Lock类的实现是RenntrantLock,是一个可重入锁。

然后还有限流器Semaphore,这个java sdk实现的一个关于信号量的方式。使用这个工具类,可以控制限制多个线程来访问,但是这多个线程是有数量限制的。

还有读写锁ReadWriteLock,读写锁实现的主要是,多个线程可以同时读一个共享变量,但是只允许一个线程写共享变量。而且写锁和读锁是互斥的,但是在写锁的前提下可以降级为读锁,但是在读锁的前提下不能升级为写锁。

还有就是ReadWriteLock的升级版本,StamedLock,这个工具类和ReadWriteLock不同的地方在于,这个工具类提供了乐观读的操作,也就是说在读的时候,可以不设置读锁,但是当需要读锁的时候,可以升级为读锁。

还有就是CountDownLatch,当要等待多个线程同时结束后进行下一步操作,可以通过CountDownLatch,设置线程的数量为CountDownLatch的计数,然后await所有的线程结束。但是要注意的是,CountDownLatch不会恢复初始计数,比如说,我设置的CountDownLatch的值是2,那么当我们每次计数down完了之后,CountDownLatch的计数就会变成0。

但是有的时候,我们是有一种业务场景,就是每次计算完后,马上就要计算下一步过来的请求。也就是一个循环操作的过程,那么我们可以使用CyclicBarrier,它在计算完成之后,CyclicBarrier的参数会自动恢复为2。

当然,也有我们的线程池Executor,还有我们经常使用的异步编程CompletableFuture。

如果有一种需求,多个子线程在执行任务,只要一个子线程执行结束了,得到的结果就是要通知所有的子线程停止执行了,那么应该如何操作。

我们完全可以使用CompletionService,把CompletionService的异步任务提交给一个Future的List集合,然后去循环查看当CompletionService中有一个线程返回结果了,那么就进行判断,并且finally将Future这个List里面的所有子任务都停掉。

Synchornized和ReentrantLock的区别?

这两个工具类最大的区别在于实现管程的方式不一样。

我们都知道,在多线程的死锁阶段,出现死锁的原因主要是有三个:

  1. 一个线程在占用了一个资源的时候,还在等待另外一个资源。
  2. 第二则是线程之间不能强行抢占其他线程占据的资源。
  3. 第三点就是两个线程之间形成了循环等待的情况。

而我们的JVM提供的Synchornized工具,只能通过破坏第一点和第三点来解决线程死锁问题,比如说第一点,我们可以提供一个单例的对象,来同时为线程提供它需要的两个资源,要么就同时给两个资源给线程,要么就都不给。而循环等待的情况,我们可以对锁进行排序,比如通过锁里面的一个字段id进行排序,先给id小的上锁,然后给id大的上锁,这样就不会出现死锁问题。

但是关于第二点,Synchornized就没有提供线程主动抢占其他线程资源的方式,因为我们解决这个问题的思路是,当一个线程获取不到另外一个资源的时候,会主动释放自己已经持有的资源,但是我们的Sychornized,在获取不到另外一个资源锁的时候,会直接陷入到阻塞状态,没有办法进行释放资源操作。这个时候,java sdk包里面提供的ReenTrantLock就有了作用。

ReenTrantLock支持响应中断,支持超时,支持非阻塞地获取锁,这三种方案都可以解决线程不能主动抢占其他其他线程资源的问题,对应的就是Lock里面的三个方法。两个tryLock方法和lockInterruptibly方法。

这也就是为什么jdk内部已经实现了管程的前提下,为什么java sdk包还要重复造轮子,创建出Lock和Condition工具类的原因。

了解NIO吗?能大概讲解一下吗

NIO是同步非阻塞的IO,主要核心是三大块,第一个是channel通道,第二是buffer缓冲区,第三是selector多路复用器。

传统IO是基于字节流和字符流进行操作的,而NIO是基于channel和buffer缓冲区进行操作,在使用NIO的时候,数据总是从channel通道成块的读取到缓冲区,或者成块的将数据从buffer缓冲区读取到channel通道,而我们selector多路复用的监视器,则是可以监视多个channel通道的事件。

要注意的是,NIO主要的不同在于,普通IO都是需要一个字节一个字节的读取或者写入,是面向IO流的,此外,普通IO不能前后移动流中的数据,如果业务上需要前后移动的话,那么就必须要将数据缓存到缓冲区。而NIO因为有buffer缓冲区,所以可以进行内存块的读取或者写入,而且在这个buffer缓冲区中,可以前后的读取数据,这就增加了处理过程中的灵活性。

而且IO的各个操作都是阻塞的,这意味着一个线程调用read()方法或者write()方法的时候,该线程是阻塞的,直到任务完成,在这个期间这个线程没有办法做任何事情。而NIO是非阻塞的,也就是说,当一个线程在读取数据的时候,如果没有读取到数据,那么这个线程是可以切换到去做其他事情的,一般我们会将这个线程的空闲时间,去执行其他channel通道的IO操作,所以一个单独的线程可以管理多个channel通道,这样可以减少对线程资源的开销。

给一个核心线程数100,最大线程数200的线程池,突然间有10000个线程突然打过来,如何处理?

一般来说,当我们在创建线程池的时候,我们都会设置几个参数,一个是corePoolSize,最小线程池。一个是maximumPoolSize,这个是线程池的最大连接数,还有一个参数是handler,是当线程池已经满了,还有其他线程过来的时候,线程池应该怎么操作。

线程池提供了四个策略给我们,第一个策略是CallerRunPolicy,就是说让提交这个任务的这个主线程自己去执行这个任务,而不是让线程池去执行。第二个策略是抛出异常,表示线程池已经满了。第三个策略是直接丢弃任务,没有任何异常抛出。第四个策略是将最早进入线程池的任务丢弃,将这个线程加入线程池。

但是当前突然有超出我们设置的最大线程数的10000个线程过来,那就意味着我们的线程池是没有办法满足业务需求的,那么如果只是简单的进行这些策略都是不合理的。这种情况,属于是单机性能上是有瓶颈的,首先我们应该将我们的服务进行分布式部署,多设置几个服务节点,但是因为我们的线程超过了我们的单体服务能够消费的数量太多,那么我们还需要研究一下这些访问是否是读取的比较多,如果都是读取的线程,那么我们可以架设一个台redis缓存服务器,这样可以减少我们服务的压力。但是如果这些并发,读取的数量不多,我们只能通过架设mq服务器,当我们nginx分发请求来的时候,先将这些请求放到我们mq中进行排队,然后我们再异步进行消费。

当然,我个人觉得,在现实情况下,出现上万的QPS还是很少的,像微博那种大访问量的服务,峰值QPS据说也是只有5000+。

Synchornized在jdk6之后的优化是什么?

  在jdk1.6之前,Synchornized只有重量级锁,没有偏向锁和轻量级锁,还有自旋锁。因此在jdk1.6之后,新加入了偏向锁和轻量级锁还有自旋锁。

首先要说一下,JVM是如何加锁的?

在JVM中的对象头里面,有一个锁结构,里面存储了一些锁的相关信息。当代码进入同步块的时候,线程就会在他所属的栈帧中生成一个锁记录的空间,用于存储当前锁对象的的锁结构的拷贝。还会将锁对象的指针指向了当前线程栈帧的锁记录的空间。

  什么是重量级锁呢?重量级锁就是说,底层是通过使用操作系统的mutex lock实现的,这就意味着所有的对象,都有一个对应的互斥锁的标记,这个标记用来保证在某一个时刻,只有一个线程可以访问这个对象,这就意味着如果多个线程来竞争这个对象的时候,这些线程会进行大量的线程状态的切换, 由于java的线程都是映射到操作系统的原生线程之上的,着就意味着如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙,而线程状态的转换需要耗费极大的处理器时间,而且还会引起操作系统的内核态和用户态的切换,因为用户态只是我们的JVM的一个状态,没有办法访问到所有的程序,而且也没有CPU的占用能力,这个时候就必须切换到内核态了,这种切换带来的性能开销也是非常大的。所以这个锁就是一个重量级锁了。

  那么jdk1.6之后新加入的偏向锁是什么呢?我们知道,在实际的开发中,可能对一个资源的竞争的线程并不是很多,甚至有的时候都只有一个线程在竞争。在这个假设的前提下,如果还是使用操作系统底层的互斥量,那肯定是极大的浪费。我们可以设定,当一个线程获得了锁以后,锁就会进入到偏向模式,这个时候对象头的Mark Word里面的部分字节更新为线程栈中的锁的地址,这个同步操作是相当耗时的,当这个线程再次请求这个锁的时候,不需要再做一系列的同步操作,就可以直接获取到锁,这样就可以大大的减少对性能的消耗。

  但是如果一旦线程的数量变多,对锁的竞争变得激烈了,那么我们的偏向锁就会失效,变成了轻量级锁。

  轻量级锁有一个假设,就是对于大部分锁来说,整个同步周期内都不存在竞争。在这个假设前提下,当有一个线程获取锁的时候,不需要调用操作系统的底层获取互斥量,而是只需要在我们的对象头的Mark Word里面的部分字节通过CAS的形式更新为线程栈中的锁的地址,如果更新成功,那就会是轻量级锁,但是如果发现对象里面已经有了其他线程更新的轻量级锁(当然会判断是否就是当前线程拥有了这个锁,如果是,那还是轻量级锁,会直接进入到同步代码块。),那么接下来就会因为对资源的竞争,膨胀为了重量级锁了。如果线程竞争严重的情况下,轻量级锁的性能,因为额外发生了CAS的操作,会比重量级锁还要差。

  而自旋锁呢?我们知道线程竞争有一个很大的性能消耗点,刚刚我也说过了,是内核态和用户态之间的切换。

  因此我们可以设定,当我们的线程,竞争不到锁的时候,不会直接陷入到阻塞状态,而是自旋一会儿,等待一会儿,在这个等待的时间里面,重新去竞争锁,如果竞争失败了,那么才会去陷入到阻塞状态。这样的操作,可以有效的解决在一个锁持有时间短,且锁竞争不激烈的环境中,减少线程状态的切换,但是要注意的是,锁的自旋如果无限下去,也是很耗费时间的,所以一般都是设置自旋10次,当然我们也可以自己设置。但是在实际的JVM运行过程中,自旋这个操作,其实是动态的。比如上一次自旋了很久没有获取到锁,那么下一次JVM就不会让线程自旋等待了。

 

Cas的ABA问题怎么解决?

  我们要知道什么CAS,CAS的意思就是比较并交换,在给一个变量赋一个新值的时候,要判断一下这个变量和我们的期望值是否一致,如果一致,就给这个变量赋新值。

  那么,所谓ABA问题是什么呢?因为我们会判断这个变量是否和期望值相等,如果相等,那意味着没有其他线程来操作过这个变量,但是还有一种情况是,有线程来操作了这个变量,但是是把这个变量从A修改为了B,又将它从B修改为了A,如果是这种情况,我们也没法知道这个变量是否有被其他线程修改过。

  那么,如何解决这个ABA问题呢?在这个变量里面加一个版本号维度就可以了,我们保证这个版本号每一次都是递增的,那么每次更新的时候,我们就可以判断一下版本号是不是和我们预期的一致就可以了。Java里面的AtomicStampedReference就是通过版本号解决了ABA问题的。

线程池的连接数设置多少个比较合适?(即是设置多少个线程?)

  在程序中的线程池中设置多少个线程连接数比较合适?

  这个要看多线程的具体应用场景,我们的程序一般都是CPU计算和I/O操作交叉执行的,由于相对于CPU来说,I/O的速度是相对比较慢的,所以大部分情况下,I/O操作的时间都比CPU操作的时间要来的长的多,这种场景我们一般都称为I/O密集型计算。那么和I/O密集型计算相对的就是CPU密集型计算了。

  一般来说,对于CPU密集型计算,多线程的目的主要还是在提升CPU的利用率,一个4核的CPU,一般就创建4个线程就可以了,再多也是在增加CPU切换的成本,当然,为了防止偶尔的内存失效或者其他原因阻塞了线程,我们一般是创建CPU核数+1的线程。

  那么,对于I/O密集型线程,我们要估算一下I/O计算的时间,和CPU计算的时间,通过I/O计算的时间,除以CPU计算的时间,然后再加1,得到的值再乘以核心数,公式就是最佳线程数=CPU核心数*(1+IO耗时/CPU耗时)。

这就是我们一般设置的线程核心数。当然这个核心数只是理论值,实际的情况怎么样,还是要经过实际的压测来决定。

使用什么工具可以确定IO耗时和CPU耗时?

以下是针对Java线程池中确定IO耗时和CPU耗时的专业工具及实施方案总结:

一、专业性能分析工具

  1. JProfiler

    • IO耗时分析‌:监控线程阻塞状态(如java.net.SocketInputStream.read阻塞),定位网络/文件IO瓶颈39
    • CPU耗时分析‌:通过CPU调用树(Call Tree)火焰图识别热点方法,精准定位计算密集型任务39
    • 优势‌:支持线程池级别监控,可视化区分IO等待与CPU执行时间
  2. Java Flight Recorder (JFR)

    • 生产环境适用‌:低开销(<2%性能影响),持续记录线程事件10

    • 关键功能‌:

      • jdk.FileRead/jdk.FileWrite事件统计IO耗时
      • jdk.CPULoad事件分析CPU占用率及热点方法310
    • 操作流程‌:

      bashCopy Code
      # 启动JFR记录
      java -XX:StartFlightRecording=duration=60s,filename=recording.jfr ...
      # 使用JDK Mission Control分析
      jmc recording.jfr
      
  3. VisualVM + BTrace脚本

    • IO监控‌:通过BTrace注入IO操作耗时统计代码(如拦截FileInputStream.read)9
    • CPU分析‌:内置抽样分析器(Sampler)定位CPU高负载线程39

二、编程接口级方案

  1. ThreadMXBean(原生API)

    • 获取CPU时间‌:

      javaCopy Code
      ThreadMXBean bean = ManagementFactory.getThreadMXBean();
      long cpuTime = bean.getThreadCpuTime(threadId); // 纳秒级CPU耗时
      long userTime = bean.getThreadUserTime(threadId); // 用户态耗时
      

      ‌:需结合任务提交/结束时间差计算纯CPU耗时5

  2. 自定义耗时拆分

    • IO耗时估算‌:

      javaCopy Code
      long start = System.nanoTime();
      performIO(); // 执行IO操作
      long ioTime = System.nanoTime() - start;
      
    • CPU耗时计算‌:总耗时 - IO耗时 = CPU耗时(适用于同步阻塞模型)6

三、操作系统级工具

  1. ‌**top -H + jstack**‌

    • 定位高CPU线程:

      bashCopy Code
      top -H -p <PID>        # 查看线程CPU占比
      printf "%x" <TID>       # 转换线程ID为十六进制
      jstack <PID> | grep -A10 <HEX_TID>  # 分析线程栈
      
    • 适用场景‌:快速识别CPU密集型线程27

  2. 异步分析工具

    • async-profiler‌:

      bashCopy Code
      ./profiler.sh -e cpu,alloc,lock -d 30 -f cpu_flame.html <PID>
      

      生成火焰图区分CPU/锁等待时间10

四、线程池监控最佳实践

监控项工具/方法目标
IO等待占比JProfiler阻塞分析/JFR I/O事件识别磁盘/网络延迟瓶颈
CPU使用率ThreadMXBean/async-profiler定位计算热点任务
线程阻塞时间JFR jdk.ThreadPark事件分析锁竞争或资源争用
任务队列堆积ThreadPoolExecutor#getQueue().size()预警线程池容量不足

关键提示‌:生产环境推荐 ‌JFR‌(低开销)或 ‌async-profiler‌(精准);开发调试优先用 ‌JProfiler/VisualVM‌ 可视化分析37。若需区分混合任务中的IO/CPU耗时,需结合代码埋点与工具链数据。

 

请大概讲一下java内存模型?都解决了什么问题?

  为什么会有java内存模型呢?我们知道,多线程产生的问题主要是三个,有序性,可见性和原子性。

  有序性产生的主要原因,是java编译器对代码的编译优化,导致了代码的顺序不同,从而出现的问题。而可见性,则是因为的我们的操作系统上,出现多核技术,即是有个多个CPU,但是每一个CPU都有自己的CPU缓存,如果每一个线程对应着一个CPU的操作,那么不同线程之间的不同CPU缓存,如果保证是相通的?这就是可见性了。

  解决这两个方法的最简单的思路,自然禁用缓存和编译优化,但是这样问题虽然解决了,但是程序的性能很明显就会受到特别大的影响。

  合理的方案自然是按需禁用缓存和编译优化。而java内存模型,则是规范了JVM如何提供按需禁用缓存和编译优化的方法。

  我们知道,java内存模型规定了每一个线程都有自己工作内存,然后所有的变量主体都存储在我们的主内存中,而工作内存中就是存储着变量的主体副本。而java提供的这些方法,主要就是为了解决了线程的工作内存之间的通信问题,以及线程的工作内存中的变量的值,在什么时间阶段刷新到我们的主内存中。

  简单点说,这些方法主要是包括了volatile,synchornized,final这三个关键字,还有happens-Before原则。

  Volatile特别容易理解,它的目的就是为了禁用CPU缓存而出现的,是为了实现线程之间的可见性。如果我们通过volatile来修饰一个变量,那么就是提醒JVM,这个变量的读取,不能通过CPU缓存,而是只能通过内存来读取。

  Synchornized相对难以理解一点,当我们通过sychornized修饰一个变量或者一个代码块的时候,我们Sychornized的内部实际上在代码块前后进行加锁和释放锁的操作的,仅仅是这样,可能还不能理解为什么Synchornized可以解决可见性问题,关键就在于java内存模型中规定happened-before原则。

  Happened-before原则,规定的是,代码前面的一个操作的结果,对于后续操作,是可见的。也就是说,happened-before原则规范了编译器的优化行为,虽然运行编译器进行优化,但是是需要遵守我们的happened-before原则的。

  Happened-before原则,有几点需要注意。第一点,程序的顺序性,代码前面对一个变量的修改,对于后续操作是可见的,这个是针对同一个线程。第二点,当使用valotile修饰一个变量的时候,这个变量是可以在后面读取到的,不管这个读取操作是不是同一个线程。第三点,synchornized修饰的代码块,多个线程之间是可以看到变量的变化的。

  当然,除了这三点以外,比如说在A线程里面调用B线程,A线程中在调用B线程之前的操作,对于B线程中是可见的,而在B线程中的操作,在A线程调用完B线程之后,在A线程中也是可见的。

  另外,这几项规则之间的可见性,是可以传递的。

  这样的happed-before原则,就可以实现我们线程之间的对变量的修改和读取之间的可见性了和代码的有序性了。

  而我们最后的final修饰一个变量的话,这是通知编译器,这个变量是永远不会变动的,可以随便进行编译优化。

  总的来说,java内存模型,主要是为了解决线程之间的有序性和可见性而诞生的。

 

并发编程中的原子性问题如何解决?

如何解决并发线程中的原子性问题呢?我们都知道,原子性问题出现的根本是线程切换的时候,因为我们的高级语言转换为CPU指令的时候,一条高级语言的指令会转换为多条CPU指令,但是我们的线程切换,是在任意一条CPU指令的时候,都可以进行切换。

这意味着什么呢?也就是说我们没有办法保证,我们这一条高级语言命令所转换为的多条CPU指令能够不被中断的执行。这种问题叫做线程的原子性问题。如果这个时候,恰好是多个线程对一个共享变量进行修改,那就会出现我们的线程安全问题。

那么我们如何去解决这个问题呢?

一般来说,我们知道,线程的原子性问题出现的原因是这一段CPU指令发生了中断切切换到其他CPU命令上去了,那么我们在这一段CPU指令集中,禁止发生CPU中断不就可以了。

可以简单的理解为,同一时刻只有一个线程执行,这个条件我们称之为互斥,如果能保证对共享变量的修改是互斥的,那么就不会原子性问题了,就线程安全了。

说白了,就是给我们需要保持原子性的代码块加锁。

Java里面提供的加锁方法有两种,一种是我们的jdk自带管程,Sychornized方法来进行修饰,第二种是使用ReentrantLock来加锁。

 

如何解决多线程中的死锁问题?

  我们知道,为了解决线程的原子性问题,我们会给一些代码块加锁,但是这样一来,就会出现死锁问题。

  什么是死锁问题呢?死锁问题就是相互竞争资源的线程因互相等待,导致了“永久”阻塞的现象。

  出现死锁的理论上要满足几个原因。

  第一点,线程之间出现了循环等待的问题。

  第二点,线程不能主动抢占其他线程已经占据的资源。

  第三点,线程占据了资源,但是还在继续等待,并且不会释放资源。

  那么,知道了这三点原因,我们只需要破坏其中的一条原因,那么我们就可以去解决死锁问题。

  对于线程之间出现了循环等待问题,我们可以通过资源排序的方式进行处理。比如说,我们的一个线程对两个资源进行加锁的时候,我们强调必须先对排序中小的那一个加锁,再对排序中大的那个资源进行加锁,这样子的话,就不会出现两个线程循环等待的问题了。

  对于线程不能主动抢占其他线程资源的问题,那么我们可以让这个线程如果需要等待其他线程释放资源,那么将自动释放自己已经占有的资源。这样也可以解决死锁问题。

  对于线程占有了资源且等待的情况,我们可以让线程一次性获取所有的资源,而不是一个一个的去获取。

 

为什么使用多线程?

为什么使用多线程?

在我们计算机发展的时候,我们的CPU,内存,I/O设备都在不断的迭代,不断朝着更快的方向努力。

  但是有一个问题却一直存在,也就是三者之间的速度差异。CPU无疑是性能最高的,内存次之,I/O设备最差。

那么操作系统为了合理的利用CPU的高性能,增加了进程和线程,可以分时复用CPU,进而均衡CPU和I/O设备的速度差异。

 

现在有很多任务,任务的数量不确定,但是要等所有的任务等到了之后,在统一处理,用java怎么实现

如果要等到所有的任务都到了之后,再统一处理,那么我们可以使用CompletableFutrue来做多线程的并行操作,我们可以通过创建一个CompletableFuture对象,然后再给这个对象创建一个专用的线程池,然后将这些任务加入到线程池中,等到所有的任务都完成了以后,我们再去执行其他的操作。

 

能大概讲一下什么是活锁吗?

什么是活锁呢?活锁就是一个线程在持有资源A的情况下去竞争资源B,另一个线程在持有B的情况下去竞争A,如果没有做阻塞中断的话,那就是我们所说的死锁了。但是即便我们做了阻塞中断,还是会有活锁的情况出现。

当一个线程持有资源A竞争不到资源B的时候,会主动释放资源A,但是恰好另一个线程是持有资源B但是竞争不到资源A的时候,也会去主动释放资源B,但是接下来第一个线程竞争到了资源B,另一个线程也同时拿到了资源A,第一个线程去竞争资源A还是拿不到,另一个线程去竞争资源B也是拿不到,这样子循环下去,就会形成一个活锁的情况。

解决活锁的办法很简单,就是让线程尝试等待一个随机时间就可以了。

 

能大概讲一下什么是线程的饥饿问题吗?

什么是饥饿的问题呢?当一个线程因为无法访问到所需要的资源,而导致它无法执行下去的情况。一般情况,如果我们给线程设定了优先级的方案,那么当我们的CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就会产生饥饿问题,或者说当一个线程持有的时间太长了,但是又没有设置这个线程的超时中断,从而引发了其他线程的长时间等待,这种情况也是属于线程的饥饿问题。

解决饥饿问题很简单,第一当然是保证资源的充足,第二是尽量去公平的分配资源,第三是避免持有锁的线程长时间执行。