Java并发知识总结

首先给大家分享一个github仓库,上面放了200多本经典的计算机书籍,包括C语言、C++、Java、Python、前端、数据库、操作系统、计算机网络、数据结构和算法、机器学习、编程人生等,可以star一下,下次找书直接在上面搜索,仓库持续更新中~

github地址:https://github.com/Tyson0314/...

如果github访问不了,可以访问gitee仓库。

gitee地址:https://gitee.com/tysondai/ja...

线程池

使用线程池的好处

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);

线程池原理

创建新的线程需要获取全局锁,通过这种设计可以尽量避免获取全局锁,当 ThreadPoolExecutor 完成预热之后(当前运行的线程数大于等于 corePoolSize),提交的大部分任务都会被放到 BlockingQueue。

Java并发知识总结_第1张图片

ThreadPoolExecutor 的通用构造函数:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);
  • corePoolSize:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。
  • maximumPoolSize:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。
  • BlockingQueue:存储等待运行的任务。
  • keepAliveTime:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止。
  • TimeUnit:时间单位

    TimeUnit.DAYS
    TimeUnit.HOURS
    TimeUnit.MINUTES
    TimeUnit.SECONDS
    TimeUnit.MILLISECONDS
    TimeUnit.MICROSECONDS
    TimeUnit.NANOSECONDS
  • ThreadFactory:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。

    public class MyThreadFactory implements ThreadFactory {
        private final String poolName;
        
        public MyThreadFactory(String poolName) {
            this.poolName = poolName;
        }
        
        public Thread newThread(Runnable runnable) {
            return new MyAppThread(runnable, poolName);//将线程池名字传递给构造函数,用于区分不同线程池的线程
        }
    }
  • RejectedExecutionHandler:当队列和线程池都满了时,根据拒绝策略处理新任务。

    AbortPolicy:默认的策略,直接抛出RejectedExecutionException
    DiscardPolicy:不处理,直接丢弃
    DiscardOldestPolicy:将等待队列队首的任务丢弃,并执行当前任务
    CallerRunsPolicy:由调用线程处理该任务

线程池大小

如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢影响体验,甚至会出现任务队列大量堆积任务导致OOM。

如果线程池线程数量过大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换(cpu给线程分配时间片,当线程的cpu时间片用完后保存状态,以便下次继续运行),从而增加线程的执行时间,影响了整体执行效率。

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止某些原因导致的任务暂停(线程阻塞,如io操作,等待锁,线程sleep)而带来的影响。一旦某个线程被阻塞,释放了cpu资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N): 系统会用大部分的时间来处理 I/O 操作,而线程等待 I/O 操作会被阻塞,释放 cpu资源,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 (1/CPU利用率) = CPU核心数 (1 + (I/O耗时/CPU耗时)),一般可设置为2N。

关闭线程池

shutdown():

将线程池状态置为SHUTDOWN,并不会立即停止:

  • 停止接收外部提交的任务
  • 内部正在跑的任务和队列里等待的任务,会执行完
  • 等到第二步完成后,才真正停止

shutdownNow():

将线程池状态置为STOP。企图立即停止,事实上不一定:

  • 跟shutdown()一样,先停止接收外部提交的任务
  • 忽略队列里等待的任务
  • 尝试将正在跑的任务中断(不一定中断成功,取决于任务响应中断的逻辑)
  • 返回未执行的任务列表

executor框架

1.5后引入的Executor框架的最大优点是把任务的提交和执行解耦。当提交一个Callable对象给ExecutorService,将得到一个Future对象,调用Future对象的get方法等待执行结果。Executor框架的内部使用了线程池机制,它在java.util.cocurrent 包下,通过该框架来控制线程的启动、执行和关闭,可以简化并发编程的操作。

简介

executor框架由3部分组成:任务、任务的执行、异步计算的结果

  • 任务。需要实现的接口:Runnable和Callable接口。
  • 任务的执行。ExecutorService 是一个接口,用于定义线程池,调用它的 execute(Runnable)或者 submit(Runnable/Callable)执行任务。ExecutorService接口继承于Executor,有两个实现类ThreadPoolExecutorScheduledThreadPoolExecutor
  • 异步计算的结果。包括future接口和实现future接口的FutureTask,调用future.get()会阻塞当前线程直到任务完成,future.cancel()可以取消执行任务。

ThreadPoolExecutor实例

使用 ThreadPoolExecutor 构造函数自定义参数的方式来创建线程池。

public class ThreadPoolExecutorDemo {
    private static final int CORE_POOL_SIZE = 5;
    private static final int MAX_POOL_SIZE = 10;
    private static final int QUEUE_CAPACITY = 100;
    private static final long KEEP_ALIVE_TIME = 1L;

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAX_POOL_SIZE,
                KEEP_ALIVE_TIME,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        for (int i = 0; i < 10; i++) {
            Callable worker = () -> {
                System.out.println(Thread.currentThread().getName());
                return "ok";
            };
            Future f = executor.submit(worker);
            f.get();
        }
        executor.shutdown();
        while (!executor.isTerminated()) {
        }
        System.out.println("Finished all threads");
    }
}

Runnable和Callable的区别

Runnable 任务执行后不能返回值或者抛出异常。Callable 任务执行后可以返回值或抛出异常。

Executors.callable(Runnable task);//runnable转化为callable
ExecutorService.execute(Runnable);
ExecutorService.submit(Runnable/Callable);//submit callable任务有返回值

//返回值是泛型参数V
public interface Callable {
    V call() throws Exception;
}

Future和FutureTask

Future 可以获取任务执行的结果、取消任务。调用 future.get()会阻塞当前线程直到任务返回结果。

public interface Future {
    boolean cancel(boolean mayInterruptIfRunning);

    boolean isCancelled();

    boolean isDone();

    V get() throws InterruptedException, ExecutionException;

    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

FutureTask 实现了 RunnableFuture 接口,而 RunnableFuture 实现了 Runnable 和 Future\ 接口。

execute()和submit()

execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否。

submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Futureget()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout, TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,无论任务是否执行完。

常用的线程池

常见的线程池有 FixedThreadPool、SingleThreadExecutor、CachedThreadPool 和 ScheduledThreadPool。这几个都是 ExecutorService (线程池)实例。

FixedThreadPool

固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
}

使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用RejectedExecutionHandler.rejectedExecution()方法。

maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。

keepAliveTime 也是无效参数,设置为0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了executor.allowCoreThreadTimeOut(true))。

不推荐使用:FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。

SingleThreadExecutor

只有一个线程的线程池。

public static ExecutionService newSingleThreadExecutor() {
    return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
}

使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。

不推荐使用:同 FixedThreadPool,在任务比较多的时候会导致 OOM。

CachedThreadPool

根据需要创建新线程的线程池。

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue());
}

如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)提交的任务会被空闲线程处理,否则会创建新的线程处理任务。

不推荐使用:CachedThreadPool允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

ScheduledThreadPoolExecutor

在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如quartz

使用的任务队列 DelayQueue 封装了一个 PriorityQueuePriorityQueue 会对队列中的任务进行排序,时间早的任务先被执行(即ScheduledFutureTasktime 变量小的先执行),如果time相同则先提交的任务会被先执行(ScheduledFutureTasksquenceNumber 变量小的先执行)。

执行周期任务步骤:

  1. 线程从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask的 time 大于等于当前系统的时间;
  2. 执行这个 ScheduledFutureTask
  3. 修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间;
  4. 把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。

Java并发知识总结_第2张图片

编码规范

阿里巴巴编码规约不允许使用Executors去创建线程池,而是通过ThreadPoolExecutor的方式手动创建线程池,这样子使用者会更加明确线程池的运行机制,避免资源耗尽的风险。

Executors 创建线程池对象的弊端:

FixedThreadPool和SingleThreadPool。允许请求队列长度为 Integer.MAX_VALUE,可能堆积大量请求,从而导致OOM。

CachedThreadPool。创建的线程池允许的最大线程数是Integer.MAX_VALUE,当添加任务的速度大于线程池处理任务的速度,可能会创建大量的线程,消耗资源,甚至导致OOM。

Java并发知识总结_第3张图片

正确示例(阿里巴巴编码规范):

//正例1
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();
//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS, //0L keepAliveTime
new LinkedBlockingQueue(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

pool.execute(()-> System.out.println(Thread.currentThread().getName()));
pool.shutdown();//gracefully shutdown

//正例2
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1, //corePoolSize threadFactory
    new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());

JMM

Java内存模型:线程之间的共享变量存储在主内存里,每个线程都有自己私有的本地内存,本地内存保存了共享变量的副本,线程对变量的操作都在本地内存中进行,不能直接读写主内存中的变量。

本地内存是JMM的一个抽象概念,并不真实存在,它包括缓存、写缓冲区、寄存器以及其他硬件和编译器优化。

Java并发知识总结_第4张图片

进程线程

进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程中可以启动多个线程。
线程是比进程更小的执行单位,它是在一个进程中独立的控制流,一个进程可以启动多个线程,每条线程并行执行不同的任务。

线程状态

初始(NEW):线程被构建,还没有调用 start()。

运行(RUNNABLE):包括操作系统的就绪和运行两种状态。

阻塞(BLOCKED):一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。

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

超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。

终止(TERMINATED):表示该线程已经执行完毕。

Java并发知识总结_第5张图片

图片来源:Java并发编程的艺术

中断

线程中断即线程运行过程中被其他线程给打断了,它与 stop 最大的区别是:stop 是由系统强制终止线程,而线程中断则是给目标线程发送一个中断信号,如果目标线程没有接收线程中断的信号并结束线程,线程则不会终止,具体是否退出或者执行其他逻辑取决于目标线程。

线程中断三个重要的方法:

1、java.lang.Thread#interrupt

调用目标线程的interrupt()方法,给目标线程发一个中断信号,线程被打上中断标记。

2、java.lang.Thread#isInterrupted()

判断目标线程是否被中断,不会清除中断标记。

3、java.lang.Thread#interrupted

判断目标线程是否被中断,会清除中断标记。

private static void test2() {
    Thread thread = new Thread(() -> {
        while (true) {
            Thread.yield();

            // 响应中断
            if (Thread.currentThread().isInterrupted()) {
                System.out.println("Java技术栈线程被中断,程序退出。");
                return;
            }
        }
    });
    thread.start();
    thread.interrupt();
}

常见方法

join

Thread.join(),在main中创建了thread线程,在main中调用了thread.join()/thread.join(long millis),main线程放弃cpu控制权,线程进入WAITING/TIMED_WAITING状态,等到thread线程执行完才继续执行main线程。

public final void join() throws InterruptedException {
    join(0);
}

yield

Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。

public static native void yield(); //static方法

sleep

Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,让出cpu资源,但不释放对象锁,指定时间到后又恢复运行。作用:给其它线程执行机会的最佳方式。

public static native void sleep(long millis) throws InterruptedException;//static方法

wait()和sleep()的区别

相同点:

  1. 使当前线程暂停运行,把机会交给其他线程
  2. 任何线程在等待期间被中断都会抛出InterruptedException

不同点:

  1. wait() 是Object超类中的方法;而sleep()是线程Thread类中的方法
  2. 对锁的持有不同,wait()会释放锁,而sleep()并不释放锁
  3. 唤醒方法不完全相同,wait() 依靠notify或者notifyAll 、中断、达到指定时间来唤醒;而sleep()到达指定时间被唤醒
  4. 调用obj.wait()需要先获取对象的锁,而 Thread.sleep()不用

创建线程的方法

  • 通过扩展Thread类来创建多线程
  • 通过实现Runnable接口来创建多线程,可实现线程间的资源共享
  • 实现Callable接口,通过FutureTask接口创建线程。
  • 使用Executor框架来创建线程池。

继承 Thread 创建线程代码如下。run()方法是由jvm创建完操作系统级线程后回调的方法,不可以手动调用,手动调用相当于调用普通方法。

/**
 * @author: 程序员大彬
 * @time: 2021-09-11 10:15
 */
public class MyThread extends Thread {
    public MyThread() {
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread() + ":" + i);
        }
    }

    public static void main(String[] args) {
        MyThread mThread1 = new MyThread();
        MyThread mThread2 = new MyThread();
        MyThread myThread3 = new MyThread();
        mThread1.start();
        mThread2.start();
        myThread3.start();
    }
}

Runnable 创建线程代码

/**
 * @author: 程序员大彬
 * @time: 2021-09-11 10:04
 */
public class RunnableTest {
    public static  void main(String[] args){
        Runnable1 r = new Runnable1();
        Thread thread = new Thread(r);
        thread.start();
        System.out.println("主线程:["+Thread.currentThread().getName()+"]");
    }
}

class Runnable1 implements Runnable{
    @Override
    public void run() {
        System.out.println("当前线程:"+Thread.currentThread().getName());
    }
}

实现Runnable接口比继承Thread类所具有的优势:

  1. 资源共享,适合多个相同的程序代码的线程去处理同一个资源
  2. 可以避免java中的单继承的限制
  3. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类

Callable 创建线程代码

/**
 * @author: 程序员大彬
 * @time: 2021-09-11 10:21
 */
public class CallableTest {
    public static void main(String[] args) {
        Callable1 c = new Callable1();

        //异步计算的结果
        FutureTask result = new FutureTask<>(c);

        new Thread(result).start();

        try {
            //等待任务完成,返回结果
            int sum = result.get();
            System.out.println(sum);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

}

class Callable1 implements Callable {

    @Override
    public Integer call() throws Exception {
        int sum = 0;

        for (int i = 0; i <= 100; i++) {
            sum += i;
        }
        return sum;
    }
}

使用 Executor 创建线程代码

/**
 * @author: 程序员大彬
 * @time: 2021-09-11 10:44
 */
public class ExecutorsTest {
    public static void main(String[] args) {
        //获取ExecutorService实例,生产禁用,需要手动创建线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        //提交任务
        executorService.submit(new RunnableDemo());
    }
}

class RunnableDemo implements Runnable {
    @Override
    public void run() {
        System.out.println("大彬");
    }
}

线程间通信

volatile

volatile是轻量级的同步机制,volatile保证变量对所有线程的可见性,不保证原子性。

  1. 当对volatile变量进行写操作的时候,JVM会向处理器发送一条LOCK前缀的指令,将该变量所在缓存行的数据写回系统内存。
  2. 由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。

MESI(缓存一致性协议):当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。

volatile关键字的两个作用:

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。

synchronized

保证线程对变量访问的可见性和排他性。synchronized 详细内容见下文锁部分。

等待通知机制

wait/notify为 Object 对象的方法,调用wait/notify需要先获得对象的锁。对象调用wait之后线程释放锁,将线程放到对象的等待队列,当通知线程调用此对象的notify()方法后,等待线程并不会立即从wait返回,需要等待通知线程释放锁(通知线程执行完同步代码块),等待队列里的线程获取锁,获取锁成功才能从wait()方法返回,即从wait方法返回前提是线程获得锁。

等待通知机制依托于同步机制,目的是确保等待线程从wait方法返回时能感知到通知线程对对象的变量值的修改。

synchronized

较常用的用于保证线程安全的方式。当一个线程获取到锁时,其他线程都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程才有机会获取到锁。

  • 修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁(类的字节码文件)
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁

获取了类锁的线程和获取了对象锁的线程是不冲突的。

释放锁

当方法或者代码块执行完毕后会自动释放锁,不需要做任何的操作。
当一个线程执行的代码出现异常时,其所持有的锁会自动释放。

实现原理

synchronized通过对象内部的监视器锁(monitor)实现。每个对象都有一个monitor,当对象的monitor被持有时,则它处于锁定的状态。

代码块的同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处或异常处。

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("method start");
        }
    }
}

线程访问同步块时,先执行monitorenter指令时尝试获取monitor,过程如下:

  1. 如果monitor的进入数entry count为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。

线程退出同步块时会执行monitorexit指令,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor。

Synchronized底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

Java并发知识总结_第6张图片

方法的同步不是通过添加monitorenter和monitorexit指令来完成,而是在其常量池中添加了ACC_SYNCHRONIZED标识符。JVM就是根据该标识符来实现方法的同步的:当线程调用方法时,会先检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,说明此方法是同步方法,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他线程无法再获得同一个monitor对象。

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

Java并发知识总结_第7张图片

锁的状态

Synchronized是通过对象内部的监视器来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间。这种依赖于操作系统Mutex Lock所实现的锁我们称之为重量级锁。

JDK1.6中为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

synchronized锁主要存在四种状态,依次是:偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

  • 偏向锁:当线程访问同步块并获取锁时,会在对象头和锁记录中存储锁偏向的线程id,以后该线程进入和退出同步块时,只需简单测试一下对象头的mark word中是否存储着指向当前线程的偏向锁,如果测试成功,则线程获取锁成功,否则,需再测试一下mark word中偏向锁标识是否是1,是的话则使用CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
    偏向锁偏向于第一个获得它的线程,如果程序运行过程,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行的开销,因为轻量级锁的获取及释放使用了多次CAS原子指令,而偏向锁只在置换ThreadID的时候使用一次CAS原子指令。当存在锁竞争的时候,偏向锁会升级为轻量级锁。
    适用场景:在锁无竞争的情况下使用,在线程没有执行完同步代码之前,没有其它线程去竞争锁,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,会做很多额外操作,导致性能下降。
  • 轻量级锁
    加锁过程:线程执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word复制到锁记录(displaced mark word)中,然后线程尝试使用cas将对象头的mark word替换为指向锁记录的指针。如果成功,则当前线程获得锁,否则表示有其他线程竞争锁,当前线程便尝试使用自旋来获得锁。当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
    解锁过程:使用原子的cas操作将displaced mark word替换回到对象头,如果成功则解锁成功,否则表明有锁竞争,锁会膨胀成重量级锁。

    在没有多线程竞争的前提下,使用轻量级锁可以减少传统的重量级锁使用操作系统互斥量(申请互斥锁)产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级锁将很快膨胀为重量级锁!

  • 重量级锁:当一个线程获取到锁时,其他线程都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程才有机会获取到锁。

    synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,保证了可见性。

  • 自旋锁:一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程比较浪费资源。自旋锁就是让该线程等待一段时间,执行一段无意义的循环,不会被立即挂起,看持有锁的线程是否会很快释放锁。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,这样反而会带来性能上的浪费。所以自旋的次数必须要有一个限度,如果自旋超过了限定次数仍然没有获取到锁,则应该被挂起。
  • 自适应自旋锁:JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
  • 锁消除:虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。
  • 锁粗化:如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗,使用锁粗化减少锁操作的开销。

ReentrantLock

重入锁,支持一个线程对资源的重复加锁。该锁的还支持设置获取锁时的公平和非公平性。

使用lock时需要在try finally块进行解锁:

public static final Object get(String key) {
    r.lock();
    try {
        return map.get(key);
    } finally {
        r.unlock();
    }
}

原理

ReentrantLock是通过组合自定义同步器来实现锁的获取与释放。当线程尝试获取同步状态时,首先判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。获取同步状态失败,则该线程会被构造成node节点放到AQS同步队列中。

如果锁被获取了n次,那么前n-1次 tryRelease(int releases)方法必须返回false,第n次调用tryRelease()之后,同步状态完全释放(值为0),才会返回true。

ReentrantLock和synchronized区别

  1. 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
  2. synchronized是非公平锁,ReentrantLock可以设置为公平锁。
  3. ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
  4. ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
  5. ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。

锁的分类

公平锁与非公平锁

按照线程访问顺序获取对象锁。synchronized 是非公平锁, Lock 默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

共享式与独占式锁

共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

悲观锁与乐观锁

悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized 和 ReentrantLock 属于悲观锁。

乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS。

乐观锁一般来说有以下2种方式:

  1. 使用数据版本记录机制实现,这是乐观锁最常用的一种实现方式。给数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的version字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
  2. 使用时间戳。数据库表增加一个字段,字段类型使用时间戳(timestamp),和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

适用场景:

  • 悲观锁适合写操作多的场景。
  • 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
CAS

CAS全称 Compare And Swap,比较与交换,是乐观锁的主要实现方式。CAS 在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock 内部的 AQS 和原子类内部都使用了 CAS。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

只有当 V 的值等于 A 时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。

以 AtomicInteger 为例,AtomicInteger 的 getAndIncrement()方法底层就是CAS实现,关键代码是 compareAndSwapInt(obj, offset, expect, update),其含义就是,如果obj内的valueexpect相等,就证明没有其他线程改变过这个变量,那么就更新它为update,如果不相等,那就会继续重试直到成功更新值。

CAS 三大问题:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从A-B-A变成了1A-2B-3A

    JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。

  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

    Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

并发工具

在JDK的并发包里提供了几个非常有用的并发工具类。CountDownLatch、CyclicBarrier和Semaphore工具类提供了一种并发流程控制的手段。

CountDownLatch

CountDownLatch用于某个线程等待其他线程执行完任务再执行,与thread.join()功能类似。常见的应用场景是开启多个线程同时执行某个任务,等到所有任务执行完再执行特定操作,如汇总统计结果。

public class CountDownLatchDemo {
    static final int N = 4;
    static CountDownLatch latch = new CountDownLatch(N);

    public static void main(String[] args) throws InterruptedException {

       for(int i = 0; i < N; i++) {
            new Thread(new Thread1()).start();
       }

       latch.await(1000, TimeUnit.MILLISECONDS); //调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行;等待timeout时间后count值还没变为0的话就会继续执行
       System.out.println("task finished");
    }

    static class Thread1 implements Runnable {

        @Override
        public void run() {
            try {
                System.out.println(Thread.currentThread().getName() + "starts working");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        }
    }
}

运行结果:

Thread-0starts workingThread-1starts workingThread-2starts workingThread-3starts workingtask finished

CyclicBarrier

CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。

public CyclicBarrier(int parties, Runnable barrierAction) {}public CyclicBarrier(int parties) {}

参数parties指让多少个线程或者任务等待至某个状态;参数barrierAction为当这些线程都达到某个状态时会执行的内容。

public class CyclicBarrierTest {
    // 请求的数量
    private static final int threadCount = 10;
    // 需要同步的线程数量
    private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5);

    public static void main(String[] args) throws InterruptedException {
        // 创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < threadCount; i++) {
            final int threadNum = i;
            Thread.sleep(1000);
            threadPool.execute(() -> {
                try {
                    test(threadNum);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            });
        }
        threadPool.shutdown();
    }

    public static void test(int threadnum) throws InterruptedException, BrokenBarrierException {
        System.out.println("threadnum:" + threadnum + "is ready");
        try {
            /**等待60秒,保证子线程完全执行结束*/
            cyclicBarrier.await(60, TimeUnit.SECONDS);
        } catch (Exception e) {
            System.out.println("-----CyclicBarrierException------");
        }
        System.out.println("threadnum:" + threadnum + "is finish");
    }

}

运行结果如下,可以看出CyclicBarrier是可以重用的:

threadnum:0is ready
threadnum:1is ready
threadnum:2is ready
threadnum:3is ready
threadnum:4is ready
threadnum:4is finish
threadnum:3is finish
threadnum:2is finish
threadnum:1is finish
threadnum:0is finish
threadnum:5is ready
threadnum:6is ready
...

当四个线程都到达barrier状态后,会从四个线程中选择一个线程去执行Runnable。

CyclicBarrier和CountDownLatch区别

CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待。

CountDownLatch用于某个线程等待其他线程执行完任务再执行。CyclicBarrier用于一组线程互相等待到某个状态,然后这组线程再同时执行。
CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置,可用于处理更为复杂的业务场景。

Semaphore

Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。

public class SemaphoreDemo {    public static void main(String[] args) {        final int N = 7;        Semaphore s = new Semaphore(3);        for(int i = 0; i < N; i++) {            new Worker(s, i).start();        }    }    static class Worker extends Thread {        private Semaphore s;        private int num;        public Worker(Semaphore s, int num) {            this.s = s;            this.num = num;        }        @Override        public void run() {            try {                s.acquire();                System.out.println("worker" + num +  " using the machine");                Thread.sleep(1000);                System.out.println("worker" + num +  " finished the task");                s.release();            } catch (InterruptedException e) {                e.printStackTrace();            }        }    }}

运行结果如下,可以看出并非按照线程访问顺序获取资源的锁,即

worker0 using the machineworker1 using the machineworker2 using the machineworker2 finished the taskworker0 finished the taskworker3 using the machineworker4 using the machineworker1 finished the taskworker6 using the machineworker4 finished the taskworker3 finished the taskworker6 finished the taskworker5 using the machineworker5 finished the task

原子类

基本类型原子类

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

AtomicInteger 类常用的方法:

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。

数组类型原子类

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

AtomicIntegerArray 类常用方法:

public final int get(int i) //获取 index=i 位置元素的值
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

引用类型原子类

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来

AQS

AQS定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch。

原理

AQS使用一个volatile的int类型的成员变量state来表示同步状态,通过CAS修改同步状态的值。

private volatile int state;//共享变量,使用volatile修饰保证线程可见性

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享 )构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。

Java并发知识总结_第8张图片

Condition

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,使用这些方法的前提是已经获取对象的锁,和 synchronized 配合使用。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式。Condition是依赖Lock对象。

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
    lock.lock();
    try {
            condition.await();
    } finally {
            lock.unlock();
    }
}
public void conditionSignal() throws InterruptedException {
    lock.lock();
    try {
            condition.signal();
    } finally {
            lock.unlock();
    }
}

一般将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁进入等待队列。其他线程调用Condition对象的signal()方法,唤醒等待队列首节点的线程。

实现原理

每个Condition对象都包含着一个等待队列,如果一个线程成功获取了锁之后调用了Condition.await()方法,那么该线程将会释放同步状态、唤醒同步队列中的后继节点,然后构造成节点加入等待队列。只有当线程再次获取Condition相关联的锁之后,才能从await()方法返回。

Java并发知识总结_第9张图片

图片来源:Java并发编程的艺术

在Object的监视器模型上,一个对象拥有一个同步队列和等待队列。Lock通过AQS实现,AQS可以有多个Condition,所以Lock拥有一个同步队列和多个等待队列。

Java并发知识总结_第10张图片

图片来源:Java并发编程的艺术

线程获取了锁之后,调用Condition的signal()方法,会将等待队列的队首节点移到同步队列中,然后该节点的线程会尝试去获取同步状态。成功获取同步状态之后,线程将await()方法返回。

Java并发知识总结_第11张图片

图片来源:Java并发编程的艺术

其他

Daemon Thread

在Java中有两类线程:

  • User Thread(用户线程)
  • Daemon Thread(守护线程)

只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。

Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是垃圾收集。

将线程转换为守护线程可以通过调用Thread对象的setDaemon(true)方法来实现。

参考资料

线程中断

synchronized实现原理

指令重排导致单例模式失效

本文已经收录到github仓库,此仓库用于分享Java相关知识总结,包括Java基础、MySQL、Spring Boot、MyBatis、Redis、RabbitMQ、计算机网络、数据结构与算法等等,欢迎大家提pr和star!

github地址:https://github.com/Tyson0314/...

如果github访问不了,可以访问gitee仓库。

gitee地址:https://gitee.com/tysondai/Ja...

你可能感兴趣的