Java 多线程和线程同步

189 阅读11分钟

进程和线程

进程和线程

  1. 操作系统中运⾏多个软件
  2. ⼀个运⾏中的软件可能包含多个进程(一个应用至少有一个进程)
  3. ⼀个运⾏中的进程可能包含多个线程
  4. 进程是资源分配的最小单位,线程是CPU调度的最小单位

CPU 线程和操作系统线程

CPU 线程

  1. 多核 CPU 的每个核各⾃独⽴运⾏,因此每个核⼀个线程

  2. 「四核⼋线程」:CPU 硬件⽅在硬件级别对 CPU 进⾏了⼀核多线程的⽀持(本质上依然是每个核⼀个线程)

  3. 操作系统线程:操作系统利⽤时间分⽚的⽅式,把 CPU 的运⾏拆分给多条运⾏逻辑,即为操作系统的线程

  4. 单核CPU 也可以运⾏多线程操作系统

线程是什么

按代码顺序执⾏下来,执⾏完毕就结束的⼀条线

  • UI 线程为什么不会结束?因为它在初始化完毕后会执⾏死循环,循环的内容是刷新界⾯,真正会导致卡死的是这个循环中的消息,如果这个消息长时间没有完成处理,就是会导致卡顿。

启动一个线程的两种方式:

/**
 * 使用 Thread 类来定义工作
 */
static void thread() {
    Thread thread = new Thread() {
        @Override
        public void run() {
            System.out.println("Thread started!");
        }
    };
    thread.start();
    thread.run(); // 会导致线程直接开始工作。
}

/**
 * 使用 Runnable 类来定义工作
 */
static void runnable() {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("Thread with Runnable started!");
        }
    };
    Thread thread = new Thread(runnable);
    thread.start();
}

上面两种方式最终都会执行到:

public void run() {
    if (this.target != null) {
        this.target.run();
    }
}

两种写法的差别就是第二种的runnable可以重用。 注意:thread.run() 会导致线程直接开始工作。 不过现在很少使用上边这种直接启动Thread的方式,一般都用线程池来做。

Callable 和 Future

与Runnable不同,Callable可以返回值,由Future来接收。

Callable

  1. 定义与作用:
    Callable<V> 是一个泛型接口,定义了一个任务,该任务可以在执行完毕后返回一个结果,并且能够抛出异常。与 Runnable 接口相比,Callable 的特点是可以返回结果和抛出检查异常。

  2. 主要方法:

    • V call() throws Exception
      这是 Callable 接口中唯一的方法,用于执行任务并返回一个结果。如果任务执行过程中出现问题,可以抛出异常。
  3. 使用场景:
    当需要在线程中执行一个任务并返回一个计算结果,或者在任务执行过程中可能发生异常时,通常会选择使用 Callable


Future

  1. 定义与作用:
    Future<V> 接口代表一个异步计算的结果。它提供了一些方法,可以用来检查任务是否完成、等待任务完成、取消任务以及获取任务的结果。通常与 Callable 配合使用。

  2. 主要方法:

    • boolean cancel(boolean mayInterruptIfRunning)
      尝试取消任务的执行,如果任务已经完成或者无法取消则返回 false
    • boolean isCancelled()
      检查任务是否在正常完成前被取消。
    • boolean isDone()
      检查任务是否已经完成(无论是正常完成、异常结束还是被取消)。
    • V get()
      获取任务的结果。如果任务尚未完成,该方法会阻塞等待任务完成后返回结果。
    • V get(long timeout, TimeUnit unit)
      在给定的超时时间内等待任务完成,超时后抛出 TimeoutException
  3. 使用场景:
    Future 常用于从线程池(例如通过 ExecutorService.submit(Callable) 提交任务)获取任务的执行结果。可以通过 Future 检查任务状态、取消任务或获取结果,从而实现异步编程模式。


Callable 与 Future 的结合使用

  1. 任务提交:
    通常,先创建一个实现了 Callable 接口的任务,然后将任务提交给一个线程池(例如 ExecutorService),提交时返回一个 Future 对象。

    ExecutorService executor = Executors.newFixedThreadPool(2);
    Callable<Integer> task = () -> {
        // 模拟计算任务
        TimeUnit.SECONDS.sleep(2);
        return 123;
    };
    Future<Integer> future = executor.submit(task);
    
  2. 获取结果:
    调用 future.get() 会阻塞当前线程,直到任务执行完成并返回结果。

    try {
        Integer result = future.get();
        System.out.println("Task result: " + result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
    
  3. 取消任务:
    如果需要取消任务执行,可以调用 future.cancel(true)。取消后的任务无法再获取正确的结果,调用 get() 时可能会抛出异常。

    future.cancel(true);
    

另一个示例:

static void callable() {
    Callable<String> callable = new Callable<String>() {
        @Override
        public String call() {
            try {
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Done!";
        }
    };

    ExecutorService executor = Executors.newCachedThreadPool();
    Future<String> future = executor.submit(callable); // 与Runnable不同,Callable可以返回值,由Future来接收。
    while (true) {
        if (future.isDone()) {
            try {
                String result = future.get();
                System.out.println("result: " + result);
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
            break;
        }
    }
}

总结

  • Callable

    • 是一个任务接口,可以返回计算结果并抛出异常。
    • 用于定义需要返回结果的并发任务。
  • Future

    • 表示异步计算的结果。
    • 提供了检查任务是否完成、等待任务完成、取消任务以及获取任务结果的方法。

通过将 CallableFuture 结合使用,可以方便地实现异步任务的提交与结果获取,同时能够处理任务执行中的异常和任务取消等情况。这种机制使得 Java 的多线程编程更加灵活和强大。

几种线程池

java.util.concurrent 包提供了多种线程池实现,常用的四种线程池通常指通过 Executors 工厂方法创建的以下几种:

  1. Cached Thread Pool

    • 创建方法: Executors.newCachedThreadPool()

    • 特点:

      • 线程数量可动态调整,适用于任务执行时间较短、执行频率较高的场景。
      • 当线程空闲超过一定时间(默认 60 秒)后会被回收。
      • 适合执行大量短期异步任务,但在任务量过大时可能会创建过多线程,消耗过多资源。
  2. Fixed Thread Pool

    • 创建方法: Executors.newFixedThreadPool(int nThreads)

    • 特点:

      • 拥有固定数量的线程,无论任务量多大,线程池内的线程数都不会超过设置的数量。
      • 适用于任务数量较为稳定,且需要限制并发线程数的场景。
      • 如果所有线程都在忙,后续任务会进入等待队列,直到有线程空闲。
  3. Single Thread Executor

    • 创建方法: Executors.newSingleThreadExecutor()

    • 特点:

      • 只有一个工作线程来执行任务,所有提交的任务都会按照先入先出的顺序执行。
      • 适用于需要保证任务顺序执行的场景。
      • 如果线程异常终止,线程池会自动创建一个新的线程来替代它,保证任务连续执行。
  4. Scheduled Thread Pool

    • 创建方法: Executors.newScheduledThreadPool(int corePoolSize)

    • 特点:

      • 用于执行延时任务或者周期性任务。
      • 提供了 schedule()scheduleAtFixedRate()scheduleWithFixedDelay() 等方法,可以灵活地设置任务的执行延时和周期。
      • 适合于定时任务调度场景,如定时数据采集、定时清理等。

注意事项

  • 线程池参数调优:
    尽管 Executors 提供的工厂方法方便快捷,但在实际项目中,有时需要对线程池参数进行细致的调优,此时可以直接使用 ThreadPoolExecutor 构造方法来创建线程池,并设置合适的核心线程数、最大线程数、任务队列以及拒绝策略。ThreadPoolExecutor 主要有以下几个参数:
 public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

  1. corePoolSize :核心池的大小,如果调用了prestartAllCoreThreads()或者prestartCoreThread()方法,会直接预先创建corePoolSize的线程,否则当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;这样做的好处是,如果任务量很小,那么甚至就不需要缓存任务,corePoolSize的线程就可以应对;

  2. maximumPoolSize:线程池最大线程数,表示在线程池中最多能创建多少个线程,如果运行中的线程超过了这个数字,那么相当于线程池已满,新来的任务会使用RejectedExecutionHandler 进行处理;

  3. keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止,然后线程池的数目维持在corePoolSize 大小;

  4. unit:参数keepAliveTime的时间单位;

  5. workQueue:一个阻塞队列,用来存储等待执行的任务,如果当前对线程的需求超过了corePoolSize大小,才会放在这里;

  6. threadFactory:线程工厂,主要用来创建线程,比如可以指定线程的名字;

  7. handler:如果线程池已满,新的任务的处理方式

  • 资源管理:
    使用完线程池后,记得调用 shutdown()shutdownNow() 方法关闭线程池,避免资源泄露。

  • 拒绝策略:
    当线程池及其队列满员时,新的任务会根据设置的拒绝策略来处理,常见策略包括:

    • AbortPolicy:默认策略,抛出 RejectedExecutionException
    • CallerRunsPolicy:由提交任务的线程执行该任务。
    • DiscardPolicy:直接丢弃任务,不抛异常。
    • DiscardOldestPolicy:丢弃队列中等待最久的任务,再尝试提交当前任务。

通过合理选择和配置线程池,可以在保证系统性能的同时,实现高效的并发任务处理。

shutdown()shutdownNow()的区别:

  • 任务接受情况:

    • shutdown():不再接受新任务,但让已提交任务继续执行。
    • shutdownNow():不仅拒绝新任务,还会尝试中断正在执行的任务,并返回未开始执行的任务列表。
  • 执行结果:

    • shutdown():线程池在所有任务完成后平缓关闭。
    • shutdownNow():线程池试图尽快关闭,但可能会中断当前任务,导致部分任务没有正常完成。

线程同步与线程安全

synchronized 的本质

  • 保证⽅法内部或代码块内部资源(数据)的互斥访问。即同⼀时间、由同⼀个 Monitor 监视的代码,最多只能有⼀个线程在访问。
  • 保证线程之间对监视资源的数据同步。即,任何线程在获取到 Monitor 后的第⼀时间,会先将共享内存中的数据复制到⾃⼰的缓存中;任何线程在释放 Monitor 的第⼀时间,会先将缓存中的数据复制到共享内存中。

volatile

  • 保证加了 volatile 关键字的字段的操作具有同步性,以及对 long 和 double 的操作的原 ⼦性(long double 原⼦性这个简单说⼀下就⾏)。因此 volatile 可以看做是简化版的synchronized。
  • volatile 只对基本类型 (byte、char、short、int、long、float、double、boolean) 的赋值操作 和对象的引⽤赋值操作有效,你要修改 User.name 是不能保证同步的。
  • volatile 依然解决不了 ++ 的原⼦性问题。

java.util.concurrent.atomic 包:下⾯有 AtomicInteger AtomicBoolean 等类,作⽤和 volatile 基本⼀致,可以看做是通⽤版的 volatile。

Lock / ReentrantReadWriteLock

同样是「加锁」机制。但使⽤⽅式更灵活,同时也更麻烦⼀些。

Lock lock = new ReentrantLock();
...
lock.lock();
try {
    x++;
} finally {  
    lock.unlock();
}

finally 的作⽤:保证在⽅法提前结束或出现 Exception 的时候,依然能正常释放锁。

⼀般并不会只是使⽤ Lock ,⽽是会使⽤更复杂的锁,例如 ReadWriteLock

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

private void count() {
  writeLock.lock();
  try {
    x++;
  } finally {
    writeLock.unlock();
  }
}

private void print(int time) {
  readLock.lock();
  try {
    System.out.print(x + " ");
  } finally {
    readLock.unlock();
  }
}

@Override
public void runTest() {
}

线程安全问题的本质

  • 在多个线程访问共同的资源时,在某⼀个线程对资源进⾏写操作的中途(写⼊已经开始,但还没结 束),其他线程对这个写了⼀半的资源进⾏了读操作,或者基于这个写了⼀半的资源进⾏了写操 作,导致出现数据错误。

  • 锁机制的本质:通过对共享资源进行访问限制,让同⼀时间只有⼀个线程可以访问资源,保证了数据的准确性。

  • 不论是线程安全问题,还是针对线程安全问题所衍⽣出的锁机制,它们的核心都在于共享的资源, ⽽不是某个⽅法或者某⼏⾏代码。

最后简单说说ReentrantReadWriteLock

ReentrantReadWriteLock不知道大家熟悉吗?其实在实际的项目中用的比较少,反正我所在的项目没有用到过。

ReentrantReadWriteLock称为读写锁,它提供一个读锁,支持多个线程共享同一把锁。它也提供了一把写锁,是独占锁,和其他读锁或者写锁互斥,表明只有一个线程能持有锁资源。通过两把锁的协同工作,能够最大化的提高读写的性能,特别是读多写少的场景,而往往大部分的场景都是读多写少的。ReentrantReadWriteLock实现了ReadWriteLock接口,可以获取到读锁(共享锁),写锁(独占锁)。同时,通过构造方法可以创建锁本身是公平锁还是非公锁。 读写锁机制:

读锁写锁
读锁共享互斥
写锁互斥互斥

线程进入读锁的前提条件:

  • 没有其他线程的写锁
  • 没有写请求,或者有写请求但调用线程和持有锁的线程是同一个线程

进入写锁的前提条件:

  • 没有其他线程的读锁
  • 没有其他线程的写锁

使用示例:

private int x = 0;
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

private void count() {
  writeLock.lock();
  try {
    x++;
  } finally {
    writeLock.unlock();
  }
}

private void print(int time) {
  readLock.lock();
  try {
    System.out.print(x + " ");
  } finally {
    readLock.unlock();
  }
}

@Override
public void runTest() {
}

上述示例实现了多线程可以同时度,但是不能同时读写的操作。读的时候用readLock,写的时候用writeLock就能够实现需要的效果。