不用多线程?你可能浪费CPU 50%的【隐形算力】

知识回顾

  • 并发(concurrent) 是同一时间同一实体应对(dealing with)多件事情的能力,在一台处理器上“同时”处理多个任务,其实只有一个事件发生。即在一段时间内,有多条指令在单个 CPU 上快速轮换、交替执行,使得在宏观上具有多个进程同时执行的效果。 比如:秒杀、多个人做同一件事。
  • 并行(parallel) 是同一时间不同实体动手做(doing) 多件事情的能力。比如:多个人同时做不同的事。

单核CPU和多核CPU的理解
单核cpu下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15毫秒)分给不同的线程使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。总结为一句话就是:微观串行,宏观并行,一般会将这种 线程轮流使用CPU 的做法称为并发,concurrent。
例如:虽然有多车道,但是收费站只有一个工作人员在收费,只有收了费才能通过,那么CPU就好比收费人员。如果有某个人不想交钱,那么收费人员可以把他“挂起”(晾着他, 等他想通了,准备好了钱,再去收费)。但是因为CPU时间单元特别短,因此感觉不出来。
多核cpu下,每个核(core)都可以调度运行线程,才能更好的发挥多线程的效率。这时候线程可以是并行的(现在的 服务器都是多核的)
➢一个Java应用程序java.exe, 其实至少有三个线程: main()主线程,gc()垃圾回收线程,异常处理线程。当然如果发生异常,会影响主线程。

一、早期的计算机:单任务时代

单任务处理
早期的计算机(如DOS系统)一次只能运行一个程序。比如,你打开一个文本编辑器写文档时,就不能同时听音乐或下载文件。

效率低下
如果程序需要等待某个操作(如读取文件或网络数据),CPU会完全空闲,浪费计算资源

二、操作系统的进化:多任务

进程(Process)的诞生
操作系统引入了多进程的概念。每个进程是一个独立运行的程序,拥有自己的内存空间。
例子:你可以一边用浏览器上网,一边用Word写文档。

问题:进程太重了
进程的创建、切换和销毁需要消耗大量资源(内存、CPU)。如果程序内部需要同时处理多个小任务(比如下载10个文件),频繁创建进程会导致性能下降

三、线程(Thread)的诞生
什么是线程?
线程是进程内部的轻量级执行单元。

一个进程可以包含多个线程,共享进程的内存和资源(如文件句柄)。

例子:在Word中,一个线程负责打字,另一个线程自动保存文档。

线程的优势

  • 更高效:线程的创建和切换比进程快得多。
  • 资源共享:线程之间可以直接共享数据,无需复杂的通信机制。
  • 提高应用程序的响应性能:比如在图形界面程序中,主线程负责界面响应,后台线程处理耗时任务(如下载文件),可增强用户体验。
  • 提高计算机系统 CPU 的利用率:多线程可以同时利用多核处理器的优势,将任务分配到不同的线程上并行执行,提高计算机资源的利用率。这在数据密集型的计算任务中尤其有效,可以大大加快任务的完成速度。
  • 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改

四、Java 为什么需要多线程?

Java 在1995年诞生时,就明确要支持多线程,原因如下:
1. 硬件发展的需求
多核CPU的出现:现代CPU有多个核心,每个核心可以同时执行一个线程。
例子:4核CPU可以同时运行4个线程,充分利用硬件资源。
Java的设计目标:Java希望成为一门跨平台、高性能的语言,必须支持并发编程。

2. 软件需求
图形界面程序:需要主线程保持界面响应,后台线程处理任务(如进度条更新)。
服务器开发:Web服务器需要同时处理成千上万个客户端请求。
游戏开发:需要同时处理用户输入、物理引擎、渲染等多个任务。

何时需要多线程?

  • 程序需要同时执行两个或多个任务
  • 程序需要实现一些需要等待的任务时,如用户输入、文件读写。操作、网络操作、搜索等。
  • 需要一些后台运行的程序时。

五、Java 如何实现多线程?
Java 通过 Thread 类和 Runnable 接口实现多线程。以下是核心概念:
1. Thread 类

// 继承 Thread 类
class MyThread extends Thread {
    public void run() {
        System.out.println("线程正在运行!");
    }
}

// 启动线程
public static void main(String[] args) {
    MyThread thread = new MyThread();
    thread.start(); // 启动线程,执行 run() 方法
}

缺点:

  • 任务逻辑写在Thread类的run方法周明刚,有单继承的局限性
  • 创建多线程时,每个任务有成员变量时不共享,必须加static才能共享

2. Runnable 接口

// 实现 Runnable 接口(更灵活,推荐使用)
class MyRunnable implements Runnable {
    public void run() {
        System.out.println("线程正在运行!");
    }
}

public static void main(String[] args) {
    Thread thread = new Thread(new MyRunnable());
    thread.start();
}

缺点:

  • 任务没有返回值
  • 任务无法抛异常给调用方

3. 线程的生命周期

  • 新建(New):线程对象被创建,但未调用 start()。
  • 就绪(Runnable):线程已启动,等待CPU分配时间片。
  • 运行(Running):线程正在执行 run() 方法。
  • 阻塞(Blocked):线程因等待I/O操作、锁等暂停执行。
  • 终止(Terminated):线程执行完毕或异常终止。

六、多线程的挑战

虽然多线程强大,但也带来问题:
1.线程安全问题
多个线程同时修改同一数据时,可能导致数据不一致。
解决方法:使用 synchronized 关键字或 Lock 接口加锁。

2. 死锁
两个线程互相等待对方释放锁,导致程序卡死。
解决方法:避免嵌套锁,按顺序获取锁。

七、Java 多线程的进化

线程池(ThreadPool)
Java 5 引入了 Executor 框架,通过池化技术复用线程,避免频繁创建销毁线程的开销。

高级并发工具
Callable 和 Future:支持带返回值的线程任务。
CountDownLatch、CyclicBarrier:协调多个线程的执行顺序。

八、总结:为什么说多线程是必然?
硬件驱动:多核CPU要求软件能够并行执行任务。
用户体验:程序需要保持响应,不能让用户等待。
效率提升:充分利用计算资源,减少空闲时间。

误用多线程会带来哪些问题?

案例举例
比如:查询用户信息接口,需要返回用户基本信息、积分信息、成长值信息,而用户、积分和成长值,需要调用不同的接口获取数据。如果查询用户信息接口,同步调用三个接口获取数据,会非常耗时。这就非常有必要把三个接口调用,改成异步调用,最后汇总结果。
再比如:注册用户接口,该接口主要包含:写用户表,分配权限,配置用户导航页,发通知消息等功能。该用户注册接口包含的业务逻辑比较多,如果在接口中同步执行这些代码,该接口响应时间会非常慢。这时就需要把业务逻辑梳理一下,划分:核心逻辑和非核心逻辑。这个例子中的核心逻辑是:写用户表和分配权限,非核心逻辑是:配置用户导航页和发通知消息。显然核心逻辑必须在接口中同步执行,而非核心逻辑可以多线程异步执行。

1. 获取不到返回值
使用线程的场景有两种:

  • 不需要关注线程方法的返回值
  • 需要关注线程方法的返回值

如果通过继承Thread类或者实现Runnable接口的方式去创建线程,那么将无法获取线程的返回值
在这里插入图片描述

解决:Java8之前用Callable接口,java8之后用CompleteFuture类实现该功能

public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
    final UserInfo userInfo = new UserInfo();
    CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteUserAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteBonusAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteGrowthAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);
    CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();

    userFuture.get();
    bonusFuture.get();
    growthFuture.get();

    return userInfo;
}

2. 数据丢失
以注册用户接口为例,该接口主要包含:写用户表,分配权限,配置用户导航页,发通知消息等功能。
其中:写用户表和分配权限功能,需要在一个事务中同步执行。而剩余的配置用户导航页和发通知消息功能,使用多线程异步执行。表面上看起来没问题。但如果前面的写用户表和分配权限功能成功了,用户注册接口就直接返回成功了。但如果后面异步执行的配置用户导航页,或发通知消息功能失败了,怎么办?如下图所示:
在这里插入图片描述
在这里可以做失败重试,但如果重试了一定的次数,还是没有成功,这条请求数据该如何处理呢?如果不做任何处理,该数据是不是就丢掉了?
为了防止数据丢失,可以用如下方案:使用mq异步处理。在分配权限之后,发送一条mq消息,到mq服务器,然后在mq的消费者中使用多线程,去配置用户导航页和发通知消息。如果mq消费者中处理失败了,可以自己重试。使用job异步处理。在分配权限之后,往任务表中写一条数据。然后有个job定时扫描该表,然后配置用户导航页和发通知消息。如果job处理某条数据失败了,可以在表中记录一个重试次数,然后不断重试。但该方案有个缺点,就是实时性可能不太高。

3. 执行顺序问题
如果你使用了多线程,就必须接受一个非常现实的问题,即顺序问题。假如之前代码的执行顺序是:a,b,c,改成多线程执行之后,代码的执行顺序可能变成了:a,c,b。(这个跟cpu调度算法有关)例如:

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> System.out.println("a"));
    Thread thread2 = new Thread(() -> System.out.println("b"));
    Thread thread3 = new Thread(() -> System.out.println("c"));

    thread1.start();
    thread2.start();
    thread3.start();
}

执行结果:

a
c
b

那么,如何保证线程的顺序呢?即线程启动的顺序是:a,b,c,执行的顺序也是:a,b,c。

3.1 joinThread类的join方法它会让主线程等待子线程运行结束后,才能继续运行。列如:

public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(() -> System.out.println("a"));
    Thread thread2 = new Thread(() -> System.out.println("b"));
    Thread thread3 = new Thread(() -> System.out.println("c"));

    thread1.start();
    thread1.join();
    thread2.start();
    thread2.join();
    thread3.start();
}

执行结果永远都是:

a
b
c

3.2 newSingleThreadExecutor我们可以使用JDK自带的Excutors类的newSingleThreadExecutor方法,创建一个单线程的线程池。例如:

public static void main(String[] args)  {
    ExecutorService executorService = Executors.newSingleThreadExecutor();

    Thread thread1 = new Thread(() -> System.out.println("a"));
    Thread thread2 = new Thread(() -> System.out.println("b"));
    Thread thread3 = new Thread(() -> System.out.println("c"));

    executorService.submit(thread1);
    executorService.submit(thread2);
    executorService.submit(thread3);

    executorService.shutdown();
}

执行结果永远都是:

a
b
c

使用Excutors类的newSingleThreadExecutor方法创建的单线程的线程池,使用了LinkedBlockingQueue作为队列,而此队列按 FIFO(先进先出)排序元素。添加到队列的顺序是a,b,c,则执行的顺序也是a,b,c。3.3 CountDownLatchCountDownLatch是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程执行完后再执行。例如:

public class ThreadTest {

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch1 = new CountDownLatch(0);
        CountDownLatch latch2 = new CountDownLatch(1);
        CountDownLatch latch3 = new CountDownLatch(1);

        Thread thread1 = new Thread(new TestRunnable(latch1, latch2, "a"));
        Thread thread2 = new Thread(new TestRunnable(latch2, latch3, "b"));
        Thread thread3 = new Thread(new TestRunnable(latch3, latch3, "c"));

        thread1.start();
        thread2.start();
        thread3.start();
    }
}

class TestRunnable implements Runnable {

    private CountDownLatch latch1;
    private CountDownLatch latch2;
    private String message;

    TestRunnable(CountDownLatch latch1, CountDownLatch latch2, String message) {
        this.latch1 = latch1;
        this.latch2 = latch2;
        this.message = message;
    }

    @Override
    public void run() {
        try {
            latch1.await();
            System.out.println(message);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        latch2.countDown();
    }
}

执行结果永远都是:

a
b
c

此外,使用CompletableFuture的thenRun方法,也能多线程的执行顺序。

  1. 线程安全问题
    既然使用了线程,伴随而来的还会有线程安全问题。假如现在有这样一个需求:用多线程执行查询方法,然后把执行结果添加到一个list集合中。代码如下:
List<User> list = Lists.newArrayList();
 dataList.stream()
     .map(data -> CompletableFuture
          .supplyAsync(() -> query(list, data), asyncExecutor)
         ));
CompletableFuture.allOf(futureArray).join();

使用CompletableFuture异步多线程执行query方法:

public void query(List<User> list, UserEntity condition) {
   User user = queryByCondition(condition);
   if(Objects.isNull(user)) {
      return;
   }
   list.add(user);
   UserExtend userExtend = queryByOther(condition);
   if(Objects.nonNull(userExtend)) {
      user.setExtend(userExtend.getInfo());
   }
}

在query方法中,将获取的查询结果添加到list集合中。结果list会出现线程安全问题,有时候会少数据,当然也不一定是必现的。这是因为ArrayList是非线程安全的,没有使用synchronized等关键字修饰。如何解决这个问题呢?
答:使用CopyOnWriteArrayList集合,代替普通的ArrayList集合,CopyOnWriteArrayList是一个线程安全的机会。只需一行小小的改动即可:List<User> list Lists.newCopyOnWriteArrayList();

  1. OOM问题
    众所周知,使用多线程可以提升代码执行效率,但也不是绝对的。对于一些耗时的操作,使用多线程,确实可以提升代码执行效率。但线程不是创建越多越好,如果线程创建多了,也可能会导致OOM异常。例如:
 Caused by: java.lang.OutOfMemoryError: unable to create new native thread

在JVM中创建一个线程,默认需要占用1M的内存空间。如果创建了过多的线程,必然会导致内存空间不足,从而出现OOM异常。除此之外,如果使用线程池的话,特别是使用固定大小线程池,即使用Executors.newFixedThreadPool方法创建的线程池。该线程池的核心线程数和最大线程数是一样的,是一个固定值,而存放消息的队列是LinkedBlockingQueue。该队列的最大容量是Integer.MAX_VALUE,也就是说如果使用固定大小线程池,存放了太多的任务,有可能也会导致OOM异常。java.lang.OutOfMemeryError:Java heap space

  1. CPU使用率飙高
    不知道你有没有做过excel数据导入功能,需要将一批excel的数据导入到系统中。每条数据都有些业务逻辑,如果单线程导入所有的数据,导入效率会非常低。于是改成了多线程导入。如果excel中有大量的数据,很可能会出现CPU使用率飙高的问题。我们都知道,如果代码出现死循环,cpu使用率会飚的很多高。因为代码一直在某个线程中循环,没法切换到其他线程,cpu一直被占用着,所以会导致cpu使用率一直高居不下。而多线程导入大量的数据,虽说没有死循环代码,但由于多个线程一直在不停的处理数据,导致占用了cpu很长的时间。也会出现cpu使用率很高的问题。那么,如何解决这个问题呢?
    答:使用Thread.sleep休眠一下。在线程中处理完一条数据,休眠10毫秒。当然CPU使用率飙高的原因很多,多线程处理数据和死循环只是其中两种,还有比如:频繁GC、正则匹配、频繁序列化反序列化等。

  2. 事务问题
    在实际项目开发中,多线程的使用场景还是挺多的。如果spring事务用在多线程场景中,会有问题吗?例如:

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel userModel) throws Exception {
        userMapper.insertUser(userModel);
        new Thread(() -> {
            roleService.doOtherThing();
        }).start();
    }
}

@Service
public class RoleService {

    @Transactional
    public void doOtherThing() {
        System.out.println("保存role表数据");
    }
}

从上面的例子中,我们可以看到事务方法add中,调用了事务方法doOtherThing,但是事务方法doOtherThing是在另外一个线程中调用的。这样会导致两个方法不在同一个线程中,获取到的数据库连接不一样,从而是两个不同的事务。如果想doOtherThing方法中抛了异常,add方法也回滚是不可能的。如果看过spring事务源码的朋友,可能会知道spring的事务是通过数据库连接来实现的。当前线程中保存了一个map,key是数据源,value是数据库连接。

private static final ThreadLocal<Map<Object, Object>> resources =

  new NamedThreadLocal<>("Transactional resources");

我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。所以不要在事务中开启另外的线程,去处理业务逻辑,这样会导致事务失效。

  1. 导致服务挂掉
    使用多线程会导致服务挂掉,这不是危言耸听,而是确有其事。假设现在有这样一种业务场景:在mq的消费者中需要调用订单查询接口,查到数据之后,写入业务表中。本来是没啥问题的。突然有一天,mq生产者跑了一个批量数据处理的job,导致mq服务器上堆积了大量的消息。此时,mq消费者的处理速度,远远跟不上mq消息的生产速度,导致的结果是出现了大量的消息堆积,对用户有很大的影响。为了解决这个问题,mq消费者改成多线程处理,直接使用了线程池,并且最大线程数配置成了20。这样调整之后,消息堆积问题确实得到了解决。但带来了另外一个更严重的问题:订单查询接口并发量太大了,有点扛不住压力,导致部分节点的服务直接挂掉。

为了解决问题,不得不临时加服务节点。在mq的消费者中使用多线程,调用接口时,一定要评估好接口能够承受的最大访问量,防止因为压力过大,而导致服务挂掉的问题。

使用多线程带来的危害参考文章:
链接:https://www.zhihu.com/question/65694908/answer/2628267050

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值