Java并发编程-多线程个人笔记

线程

创建线程的方法?

其实都是调用new Thread.start(),不直接重写run是因为:重写run只是重写了main线程自己的run,和就在主线程里跑一个普通方法没区别,只有用Thread.start()方法才能初始化一个新的线程来run

这里给出五种

// 继承 Thread 类
public class ExtendsThread extends Thread {
    @Override
    public void run (){
        System.out.println("111");
    }
    public static void main (String[] args) {
        new ExtendsThread().start();
    }
}

// 实现 Runnable 接口
public class ImplementsRunnable implements Runnable {
    @Override
    public String call() throws Exception {
        System.out.println("111");
        return "success";
    }
    public static void main (String[] args) {
        ImplementsRunnable runnable = new InplementsRunnable();
        new Thread(runnable).satrt();
    }
}

//  可以拿到线程执行完的返回值
public class ImplementsCallable implements Callable {
    @Override
    public void run {
        System.out.println("111");
    }
    public static void main (String[] args) {
        ImplementsCallable callable = new ImplementsCallable();
        FutureTask<String> futureTask = new FutureTask<>(callable);
        new Thread(futureTask).start();
        System.out.println(futureTask.get());
    }
}

// (可以更灵活的自定义线程池七大参数)或者Executor(不推荐,它的线程池不够灵活)
public class UnseExecutorService {
    public static void main (String[] args) {
        // ExecutorService,直接建立线程池
        ExecutorService poolA = Executors.newFixedThreadPool(2);
        poolA.execute(()->{
            System.out.println("4A......");
        });
        poolA.shutdown();
        // 或者使用ThreadPoolExecutor自定义线程池参数
        // 参数按顺序为:核心线程数、最大线程数、空闲线程存活时间、时间单位、工作队列、线程工厂、拒绝策略
        ThreadPoolExecutor poolB = new ThreadPoolExecutor(2, 3, 0,
                TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(3),
                Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
        poolB.submit(() -> {
            System.out.println("4B..............");
        });
        poolB.shutdown();
    }
}

//  java8的新类,可以用来执行异步任务
public class UseCompletableFuture {
    public static void main (String[] args) throws InterruptedException {
        CompleteFuture<String> cf = CompletableFuture.supplyAsync(() -> {
            System.out.println("111");
            return "111";
        });
        // 需要阻塞,否则看不到结果
        Thread.sleep(1000);
    }
}

线程状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start() 。

  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。

  • BLOCKED:阻塞状态,需要等待锁释放。

  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。

  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

  • TERMINATED:终止状态,表示该线程已经运行完毕。

什么是线程上下文切换?

上下文切换就是线程之间出现切换时保存自己的信息,以便于下次切换回来能够续点继续运行,每个进程有自己的即刻状态和运行条件,就像程序计数器那样.因为操作系统不会让cpu一直被一个线程占用,而是会给每个线程分配时间片来防止某个线程被饿死.而在以下几种情况时线程会进行上下文切换:

  • cpu时间片耗尽

  • 主动退出,比如调用了sleep()和wait()方法

  • 调用阻塞类型的系统中断,比如请求IO

  • 被终止或者结束运行

上下文切换每次需要保存信息和恢复信息,因此是一个消耗比较大的操作,要尽量避免.

(用Linux系统的原因之一就是,它上下文切换和模式切换(切线程要进入内核态)的时间非常少)

sleep()方法和wait()方法对比?

sleep()wait()
不释放锁释放锁
通常用于暂停执行通常用于进程间交互/通信(notify)
会自动苏醒不会自动苏醒,要等待其他线程调用notify() -- 也可以用wait(long timeout)自动苏醒
Thread类的静态本地方法 -- (定义在Thread而不是Object的原因: sleep()不需要操作对象,只是暂停当前线程)Object类的本地方法 -- (定义在Object而不是Thread是因为: wait()需要释放线程占有的对象锁并进入WATTING状态,是对'对象'操作)

多线程

并发与并行的区别?

并发: 在同一时间段内,两线程都可以运行 (19:00 - 19:01 A -> B -> A)

并行: 在同一时间点上,两线程同时运行 (19:01 A and B)

同步和异步的区别?

同步: 发出调用后, 直到结果返回, 调用才可以返回

异步: 发出调用后, 不需要等待结果, 直接返回

为什么用多线程?

  • 线程切换花销比进程小多了

  • 计算机现在基本都是多核,需要多线程才能充分利用

多线程的潜在风险?

内存泄漏(OOM问题)

  • 因为Java不像C++,他有自己的GC(垃圾收集器),所以理论上Java 不会出现内存泄漏. 但是实际项目实践会遇到很多内存泄漏, 主要有以下五个.

  • 静态集合类引起内存泄漏:

  • static Vector vector = new Vector(5);
    for (int i = 1; i<1000; i++){
        Object object = new Object();
        vector .add(object);
        object = null;
    }
  • 如果我们定义了一个静态集合,又没有在用完之后及时把它置空,就会导致内存泄漏

  • 静态集合的生命周期和应用程序本身相同,而他的内部对象虽然是短生命周期,但是却由于一直被静态集合持有引用而无法回收.

  • 解决办法:及时置空或者干脆少用

  • 资源未关闭或释放

    • 如果我们在try-catch块里面开了一个流或者新建一个网络连接(比如: 网络连接,数据库连接,IO流等), 此时JVM会分配内存做缓存. 如果忘记关闭这些资源就会阻塞内存,导致GC无法进行清理.

    • 解决方案: 在finally里面关闭资源, 或者使用try with resource语法

  • 错误的equals()或hashCode()

    • 如果一个自定义额对象没重写equals()或者hashCode(),Map和Set会调用Object.hashCode()方法,默认的hashCode()方法是通过XOR-SHIFT算法来生成伪随机数哈希值的,于是Map和Set认为每一个put()进来的对象都不相同,导致不断插入重复对象,造成内存泄漏

    • 但其实,重写了hashCode()也会有新的问题:

      • 在修改了Map或者Set中的对象之后,其hash值也会变化,(此时对象的hash值和原先的Node位置不是对应的)导致remove的时候找不到对应Node.

    • 解决方案:重写equals()和hashCode(), 并且需要修改M或S中的对象时先remove(), 再修改属性, 再重新add进去

  • 重写了finalize()的类

    public class ThreadTest{
    
             @Override
                protected void finalize() throws Throwable {
                while (true) {
                       Thread.yield();
                  }
             }
            public static void main(String[] args){
                    while (true) {
                    for (int i = 0; i < 100000; i++) {
                            ThreadTest force = new ThreadTest();
                    }
               }
            }
    }

  • 重写了finalize()的类的对象不会被立即垃圾回收,GC把它们排到最后,最后在某个时间点回收.

  • 如果finalize()重写不合理,或者finalize队列跟不上GC速度,那么迟早会内存泄漏

  • 解决方案: 尽量别用finalize()

  • 使用ThreadLocal不当

    • ThreadLocal提供线程本地变量副本,保证了线程隔离从而保证线程安全.但是使用不当也会造成内存泄漏

    • 一旦线程不存在, 就应该回收掉ThreadLocal.但现在用的都是线程池,它有线程重用的功能,因此线程就不会被GC回收.所以在使用线程池中的ThreadLocal时,如果没有显式的删除它,就会一直保存在内存中,不被GC

    • 解决方案: 当不再使用ThreadLocal时, 调用remove()删除它, 不要使用ThreadLocal.set(null), 他只是查找与当前线程关联的Map,然后设置键值对为null.

    • 注意用try-catch的时候在finally里关

死锁

  • 概念 :线程A持有资源1, 线程B持有资源2, 两个线程都想要对方的资源且不愿意放弃持有的资源.

  • 产生死锁的四个必要条件与对应的破坏方法:

    • 互斥条件:

      • 该资源任一时刻只允许一个线程占用

      • 破坏方法:没法破坏,这本身就是多线程的意义

    • 请求与保持条件:

      • 一个线程因请求资源被阻塞时,不愿放弃已经占有的资源

      • 破坏方法:线程一次性请求所需的所有资源, 如果不能全部拿到, 那就全部不要

      • (这样如果因为请求资源被阻塞, 就不会有已经占有的资源; 如果已经占有了资源,就不会再去请求资源)

    • 不剥夺条件:

      • 线程自身已经占有的资源在使用完之前不能被其他线程强制剥夺,只能自己用完主动释放

      • 破坏方法:占有部分资源的线程如果申请剩余资源失败, 就主动放弃已经占有的资源

      • (不能被剥夺,那就主动放弃)

    • 循环等待条件:

      • 几个线程之间形成一种循环等待的资源等待关系(A要1, 2; B要2, 1)

      • 破坏方法: 按固定顺序申请资源, 按相反顺序释放资源

      • (这样可以避免出现循环等待)

  • 如何检查是否发生死锁:

    • 用jstack命令看JVM线程和栈和堆内存的情况, 如果有死锁, jstack的输出一般会有Found one Java-level deadlock:这种信息

    • 也可以用jdk自带的jconsole工具连到程序然后进到对应线程, 点检测死锁就行.他会报堆栈跟踪信息.

线程不安全问题:

  • 多个线程对同一个资源进行写操作, 产生的结果和我们预计的不一样, 就出现了线程不安全问题.

  • 比如说hashmap1.7里的死链和数据丢失, 1.7和1.8里都有的put数据覆盖问题.

  • 解决方案:

    • synchronized关键字加锁:

      • 底层实现主要是通过monitorenter 与monitorexit计数

      • 可重入

      • 注意锁升级机制

    • ReentrantLock

      • 可重入

      • 灵活,但要记得释放锁

      • 公平

      • 等待可中断

    • ThreadLocal

      • 线程内存隔离

    • 乐观锁

      • 用版本号version控制

并发特性

  • 原子性

    • 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

    • 实现方案:用ynchronized/各种Lock/各种原子类

      • synchronized和lock是通过加锁保证

      • 各种原子类是通过CAS操作保证(可能也会用volatile 和 final)

  • 可见性

    • 各个线程都能看到一样的该变量随着写操作而即时变动的最新值

    • 实现方案:synchronized/各种Lock/volatile

    • 也就是告诉JVM,这个变量是共享而且多变的,每次都从主存中读取

  • 有序性

    • 由于编译器的指令优化重排和处理器的指令并行重排,导致代码并不一定按照编写顺序执行

    • 实现方案:volatile指令重排

      public class SingletonDemo {
          private static SingletonDemo singletonDemo=null;
          private SingletonDemo(){
              System.out.println(Thread.currentThread().getName()+"\t 我是构造方法");
          }
          //DCL模式 Double Check Lock 双端检索机制:在加锁前后都进行判断
          public static SingletonDemo getInstance(){
              if (singletonDemo==null){
                  synchronized (SingletonDemo.class){
                      if (singletonDemo==null){
                          singletonDemo=new SingletonDemo();
                      }
                  }
              }
              return singletonDemo;
          }
          public static void main(String[] args) {
              for (int i = 0; i < 10; i++) {
                  new Thread(()->{
                      SingletonDemo.getInstance();
                  },String.valueOf(i+1)).start();
              }
          }
      }
      %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
      memory = allocate();     //1.分配内存
      instance(memory);         //2.初始化对象
      instance = memory;         //3.设置引用地址

  • 其中2、3没有数据依赖关系,可能发生重排。如果发生,此时内存已经分配,那么instance=memory不为null。如果此时线程挂起,instance(memory)还未执行,对象还未初始化。由于instance!=null,所以两次判断都跳过,最后返回的instance没有任何内容,还没初始化。

volatile关键字

如何保证可见性?

  • volatile关键字告诉编译器:该变量是共享且写操作频繁的,因此每次使用它应该去主存里读取,而不是在线程的本地内存中读共享变量副本.

如何保证有序性?

  • volatile可以为被声明的变量插入内存屏障来禁止指令重排,从而保证有序性(Java在Unsafe类里面提供了内存屏障相关的方法,屏蔽了操作系统底层的差异,效果和volatile一样,但是实现比较麻烦)

  • 双重校验锁实现单例模式时会用到volatile防止指令重排的功能

Volatile可以保证原子性吗?

  • 不能.

  • 比如多线程下的自增运算(读取 ->+1 -> 写回),两个线程同时读到该变量,同时+1,然后写回内存, 实际上只加了1(而不是2)

  • 这种情况可以用synchronized,ReentrantLock(记得释放锁)或者AtomicInteger

乐观和悲观锁

悲观锁

  • 悲观锁认为数据在每次被访问的时候都容易出现问题(也就是写操作频繁),于是它在每次获取资源的时候都会上锁,避免出现线程不安全问题.

  • synchronized和ReentrantLock等独占锁就是悲观锁实现

  • 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行

乐观锁

  • 乐观锁认为每次访问的时候不会出问题,(也就是读操作频繁),线程可以随便不停执行

  • 版本号机制和CAS算法就是乐观锁实现

    • 版本号要在数据表里加一个版本号字段,数据库有事务机制来一起保证不出现数据覆盖

    • 原子类中比如AtomicInteger和LongAdder用的CAS.

      • CAS是原子操作,不会中断,通过对比预期值和实际值来进行数据修改

      • CAS存在ABA问题,也就是无法获知历史变更,可以加版本号或者时间戳解决

      • CAS失败可反复重试,但是长时间充实会导致cpu飙升.可以用处理器提供的pause指令提升效率,可以延迟流水线处理指令,避免cpu消耗过多资源;可以避免退出循环的时候因内存顺序冲突导致cpu流水线被清空,提升执行效率

  • 不会死锁,不会激烈锁竞争导致线程阻塞.

synchronized关键字

是什么?

  • 为了解决多个线程访问同一资源的同步性, 可以保证被它修饰的方法或者代码块在任一时刻只有一个线程执行

  • Java早期版本是重量级锁,效率低下,因为监视器锁依赖于底层操作系统的Mutex lock,Java的线程是映射到操作系统的原生线程上的,操作线程需要从用户态转到内核态,状态转换时间成本高.

  • Java6引入自旋锁、适应性自旋锁、偏向锁、轻量级锁、锁消除、锁粗化等技术来减少锁操作的开销

怎么用?

  • 修饰实例方法(锁当前对象实例)

    • 获得当前对象实例的锁

  • 修饰静态方法(锁当前类)

    • 获得当前类的锁,因为静态方法是属于类的,不依赖于某个实例

    • 类锁和实例锁不互斥

  • 修饰代码块

    • 对括号里指定的对象/类加锁

    • 尽量不要使用synchronized(String a),因为在JVM里面,字符串常量池有缓存功能

  • 不能用于修饰构造方法,构造方法本身就是线程安全的,但是可以修饰构造方法中的代码块来保证其内部对共享资源的安全操作.

底层原理?

  • 同步代码块

    • 这是JVM层面的东西:通过monitorenter指令和monitorexit指令尝试获取锁和释放锁,这里的锁是指对象监视器(monitor)的持有权

    • monitorenter指令指向语句块开始,会尝试获取锁,锁计数器为0就拿,拿了之后锁计数器就变成1

    • monitorexit指令指向语句块结束,释放锁,把锁计数器设为0

  • 同步方法

    • 没有上面那两个指令,用的是ACC_SYNCHRONIZED标识,表明该方法是一个同步方法,让JVM执行同步调用

    • 实例方法获取对象实例的锁,静态方法获取当前class的锁

  • 两者的本质都是对象监视器monitor的获取

JDK1.6进行的优化

锁的四种状态

参考: 关于 锁的四种状态与锁升级过程 图文详解 - 牧小农 - 博客园

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到索竞争的线程,使用自旋会消耗CPU追求响应速度,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较慢
  • 无锁

    • 无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

    • 无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

  • 偏量锁(JDK15的时候默认关闭, JDK18的时候彻底删除 -- 偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升)

    • 初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

    • 偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

    • 当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

    • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

    • 关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

  • 轻量级锁

    • 轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。

    • 轻量级锁的获取主要由两种情况: ① 当关闭偏向锁功能时; ② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

    • 一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

    • 在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

    • 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  • 重量级锁

    • 就是JDK1.6的synchronized, 其他线程全部阻塞

    • 轻量级锁忙等重试次数达到10次时转成重量级锁,可以通过虚拟机参数改变这个次数阈值

锁消除和锁粗化

  • 锁消除

    • 原理是基于逃逸分析,通过分析程序的执行路径,确定某个对象是否会被多个线程访问到。如果逃逸分析发现某个对象不会逃逸出线程的上下文,即不可能被其他线程访问到,那么编译器就会将对该对象的锁消除掉。

    • 适用场景

      • 某个对象的作用域仅限于单个线程,不会被其他线程引用或访问。

      • 某个对象是局部变量,并且不会被传递到其他方法中。

      • 某个对象是线程私有的,不会被多个线程共享。

  • 锁粗化

    • 原理是基于对代码执行性能的优化考虑。当一段代码中包含多个连续的对同一个对象的加锁和解锁操作时,这些细粒度的锁操作会增加线程竞争的开销。通过将这些锁操作合并成一个锁操作,可以减少线程竞争的发生和锁的获取和释放操作的次数。

    • 适用场景

      • 一段代码中包含多个连续的对同一个对象的加锁和解锁操作。

      • 这些锁操作之间没有其他的耗时操作,且不会造成线程竞争。

    • 过度的锁粗化可能导致锁的持有时间过长,影响其他线程的并发执行能力,需要根据实际情况进行评估和优化。

ReentrantLock

是什么?

  • 实现了Lock接口,可重入且独占,和synchronized关键字相似,但是更灵活和强大.增加了轮询/超时/中断/公平和非公平锁选择等高级功能

    • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

    • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

  • 底层用AQS实现

和Synchronized的区别

  • 两者都是可重入锁,可以重复获得自己已经获得的锁,如果不可重入会造成死锁(比如先后调用同一个对象的两个方法,获取了两个锁,如果不可重入,在调用第二个方法的时候会死锁)

  • synchronized依赖于JVM,ReentrantLock依赖于API,jdk1.6做的优化很多是在JVM层面的,没有直接暴露给我们,而ReentrantLock我们可以看源码

  • ReentrantLock实现了一些高级功能(都是synchronized不行的)

    • 等待可中断:通过lock.lockInterruptibly()实现该机制,正在等待的线程可以放弃等待做其他事

    • 可实现公平锁:RtL可以选择公平还是非公平(默认是非公平,通过构造方法指定是否公平),scnd 只能用非公平锁.

    • 可实现选择性通知(锁可以绑定多个条件):scnd与wait()和notify()/notifyAll()方法结合实现等待/通知机制.RtL也可以实现,但是需要借助Condition接口和newCondition()方法

      • 线程可以注册在指定的Condition中,从而用某个Condition的signalAll()方法实现选择性通知其中线程

      • 而scnd相当于所有线程都在一个Condition中,notifyAll()会唤醒所有线程,效率很低

  • scnd不可中断,RtL可中断

ReentrantReadWriteLock

是什么?

  • RtRWL实现了ReadWriteLock接口,是一个可重入的读写锁

    • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥

    • 读写锁进行并发控制的规则:读读互斥、读写互斥、写写互斥(只有读读不互斥)

    • 实际上是两把锁(一读一写)

一些机制问题

  • 实际上是读锁和写锁两把锁,读锁是共享锁,可以同时被多个线程持有,而写锁是独占锁,只能同时被一个线程池有

  • 该线程占有读锁的时候不可以获取写锁(因为获取写锁时发现读锁被占用就会直接失败)

  • 该线程占有写锁的时候可以获取读锁(只有获取读锁时如果发现写锁被占用,只有写锁被非当前线程占用时才会失败)

  • 读锁为什么不能升级成写锁?

    • 写锁可以降级为读锁,读锁不能升级为写锁。因为会引起线程的争夺,影响性能。

    • 同时也可能造成死锁,两个线程同时想升级为写锁,都要对方释放读锁,都不释放,进入死锁。

场景

可以保证多读的效率,也可以保证多写的线程安全,所以读多写少的情况下好用

StampedLock

  • jdk1.8出来的性能更好的的读写锁

  • 不可重入

  • 提供三种读写控制模式(独占锁,悲观读,乐观读-允许一个写线程获取写锁,防止读多写少场景下写锁饥饿)

  • 用一个long型的数据戳

  • 一般来说比RtRWL好用,但是StampedLock不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

Atomic原子类

xxx

ThreadLocal

是什么?

  • 各线程的私有数据、专属本地变量

  • 如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

  • 实际上变量是存在ThreadLoacalMap上的,ThreadLocal只是个传递变量值的封装

怎么用?

import java.text.SimpleDateFormat;
import java.util.Random;x
public class ThreadLocalExample implements Runnable{
     // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
    public static void main(String[] args) throws InterruptedException {
        ThreadLocalExample obj = new ThreadLocalExample();
        for(int i=0 ; i<10; i++){
            Thread t = new Thread(obj, ""+i);
            Thread.sleep(new Random().nextInt(1000));
            t.start();
        }
    }
    @Override
    public void run() {
        System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //formatter pattern is changed here by thread, but it won't reflect to other threads
        formatter.set(new SimpleDateFormat());
        System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
    }
}

ThreadLocal的内存泄露问题

  1. ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

  2. 这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法

线程池

创建线程池的方法?

// (可以更灵活的自定义线程池七大参数)或者Executor(不推荐,它的线程池不够灵活)
public class UnseExecutorService {
    public static void main (String[] args) {
        // ExecutorService,直接建立线程池
        ExecutorService poolA = Executors.newFixedThreadPool(2);
        poolA.execute(()->{
            System.out.println("4A......");
        });
        poolA.shutdown();
        // 或者使用ThreadPoolExecutor自定义线程池参数
        // 参数按顺序为:核心线程数、最大线程数、空闲线程存活时间、时间单位、工作队列、线程工厂、拒绝策略
        ThreadPoolExecutor poolB = new ThreadPoolExecutor(2, 3, 0,
                TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(3),
                Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
        poolB.submit(() -> {
            System.out.println("4B..............");
        });
        poolB.shutdown();
    }
}

线程池参数?

  • 参数按顺序为:核心线程数、最大线程数、空闲线程存活时间、时间单位、工作队列、线程工厂、拒绝策略

  • *核心线程数*(corePoolSize):任务队列未达到队列容量时,最大可以同时运行的线程数量。

  • *最大线程数*(maximumPoolSize):任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

  • 空闲线程存活时间(keepAliveTime):线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。

  • 时间单位(unit):keepAliveTime 的时间单位

  • 工作队列(workQueue):新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  • 线程工厂(threadFactory):executor 创建新线程的时候会用到。

  • 拒绝策略(handler):拒绝策略

线程拒绝策略?

  • 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些拒绝策略:

    • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。

    • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

    • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。

    • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

  • 举个例子:Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor 将抛出 RejectedExecutionException 异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。

CallerRunsPolicy拒绝策略有什么风险?如何解决?

  • CRP拒绝策略可以保证任何一个任务请求都被执行,但是如果走到CRP的任务非常耗时,而且还是主线程提交的,可能会导致主线程阻塞,进而导致后续任务无法执行,严重的可能会造成OOM。

  • 解决方案:

    • 从资源角度解决:

      • 内存允许的前提下增加阻塞队列大小并调整堆内存以适应更多的任务;

      • 增大最大线程数参数以增加cpu处理速度,避免阻塞队列等待任务过多导致内存用完

    • 从任务持久化角度解决:

      • 设计一张任务表,将任务存储到MySQL数据库中

        • 实现RejectedExecutionHandler接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。

        • 继承BlockingQueue实现一个混合式阻塞队列,该队列包含JDK自带的ArrayBlockingQueue。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写take()方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 ArrayBlockingQueue中去取任务。

      • Redis缓存任务

      • 将任务提到消息队列中

    • 其他主流框架的拒绝策略

      • Netty:直接在线程池外面开线程(需要好的硬件设备,无法准确监控线程池外创建的线程)

      • ActiveMQ:尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付

线程池常用的阻塞队列有哪些?

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • 容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。

  • SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。

  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

线程池处理任务的流程是什么?

  • 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。

  • 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。

  • 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。

  • 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。

线程池中线程异常了会发生什么?

  • 分两种情况,使用execute()提交任务时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()提交任务时,异常被封装在Future中,线程继续复用。

  • 这种设计允许submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景。

如何给线程池命名?

平时用线程池的时候,默认名是pool-1-thread-n,很难定位问题,所以最好创线程池的时候自己命名

ThreadFactory threadFactory = new ThreadFactoryBuilder()
                        .setNameFormat(threadNamePrefix + "-%d")
                        .setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(
                            corePoolSize, 
                            maximumPoolSize, 
                            keepAliveTime, 
                            TimeUnit.MINUTES, 
                            workQueue, 
                            threadFactory
                            );

如何设定线程池大小?

  • 线程池太小:如果同一时间大量任务请求需要处理,可能会导致大量任务堆积在任务队列出现OOM,cpu也没有得到充分利用。

  • 线程池太大:大量线程同时争夺cpu资源,导致大量上下文切换,影响整体效率

  • 如何设计

    • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

    • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

  • 如何判断是CPU密集型还是I/O密集型:

    • CPU密集型:利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。

    • I/O密集型:涉及到网络读取,文件读取这类

  • ThreadPoolExecutor可以动态调整线程池参数(除了队列长度不能动态指定其他都行)

    • 美团的方案是:自定义了一个叫ResizableCapacityLinkedBlockIngQueue的队列,主要就是把LinkedBlockingQueue的capacity字段的final关键字修饰去掉了,让它变成可变的。

如何设计一个能够根据任务优先级来执行的线程池?

  • PriorityBlockingQueue(支持优先级的无界阻塞队列):可以看成是线程安全的PriorityQueue,都是用的小顶二叉堆实现,只是后者不支持阻塞。

  • 传入的任务必须要具备排序能力

    • 任务实现Comparable接口并重写compareTo方法来指定优先级顺序

    • 创建PriorityBlockingQueue的时候传入一个Comparator对象来指定任务之间的排序规则

  • 但是也存在风险

    • PBQ是无界的,可能OOM

      • 解决方案:继承PBQ,重写offer方法(入队)的逻辑,超过指定值就返回false

    • 可能会有饥饿问题,低优先级任务长时间没执行

      • 解决方案:重新设计,等太久就移除并重新添加到队列,调高优先级

    • 要对队列中的元素进行排序,还要保证线程安全(并发控制用的可重入锁ReentrantLock),降低性能

      • 解决方案:没办法

Future

是什么?

  • 用来做异步功能,主要用于一些耗时场景,避免原地等待任务完成。

  • 在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

    • 取消任务;

    • 判断任务是否被取消;

    • 判断任务是否已经执行完成;

    • 获取任务执行结果。

CompleteFuture

  • Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

  • Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

AQS

是什么?

  • 全称为AbstractQueueSynchronizer(抽象队列同步器),在java.util.concurrent.locks包下面,是个抽象类,主要用来构建锁和同步器。

原理?

  • AQS的核心思想是:

    • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。

    • 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制(线程排队等待队列),这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

  • CLH是个虚拟的双向队列(不存在队列实例,只是存在类似的结点关系),AQS将线程封装成CLH的一个结点(包含:线程引用thread、当前节点在队列中的状态withStatus、前驱节点prev、后继节点next)

  • 同时AQS还用一个private volatile int state表示同步状态

  • 实现案例:

    • 以 ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。

    • 再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown() 一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。

Semaphore了解吗?

不怎么常用

作用?

  • 通过设置许可证数量限制访问线程数量的共享锁,访问数量设置为1就退化成排他锁。

  • synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

原理?

  • 默认构造AQS的state值为permits,也就是许可证的数量,只有拿到许可证的线程能执行。

  • 调用semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。

  • 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。

CountDownLatch了解吗?

常用

作用?

  • CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

  • CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

原理?

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行

场景?

CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。比如分批处理一个有五个大文件夹的硬盘的数据,定义一个线程池和count为5的CountDownLatch对象,每一个线程执行完就count-1,等count=0的时候汇总硬盘数据执行后面的逻辑,就可以做到并行读一个硬盘。

改进?

  • CompletableFuture类:异步、串行、并行或者等待所有线程执行完任务都方便

  • 任务过多的话每一个task都列出来不太现实,可以循环添加任务

//文件夹位置
List<String> filePaths = Arrays.asList(...)
// 异步处理所有文件
List<CompletableFuture<String>> fileFutures = filePaths.stream()
    .map(filePath -> doSomeThing(filePath))
    .collect(Collectors.toList());
// 将他们合并起来
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
    fileFutures.toArray(new CompletableFuture[fileFutures.size()])
);

CyclicBarrier了解吗?

作用?

  • 和CountDownLatch很像,也可以实现线程间的技术等待,但是功能更复杂强大,应用场景和CountDownLatch类似。

    • CountDownLatch是基于AQS的,而CyclicBarrier是基于ReentrantLock(RL基于AQS)和Condition的。

  • 字面意思是“可循环使用的屏障”,也就是一组线程的最后一个线程到达屏障(同步点)的时候,屏障才会打开,让这组被拦截的线程继续干活。

原理?

  • 维护private final int parties(每次拦截的线程数)和 private int count(计数器)。count初始化为parties,每当一个线程到达屏障,count减1,count=0时执行构造方法中输入的任务。

虚拟线程

(JDK21出的,对标go的协程)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值