Java八股文纯享版——篇②:并发编程

注:
1.笔记为个人归纳整理,尽力保证准确性,如有错误,恳请指正
2.写文不易,转载请注明出处
3.本文首发地址 https://blog.leapmie.com/archives/c02a6ed1/
4.本系列文章目录详见《Java八股文纯享版——目录》
5.文末可关注公众号,内容更精彩

Java创建线程的方法

方式一:继承Thread类的方式

继承于Thread类,重写Thread类中的run()方法,创建子类对象,调用start()方法。

public class Demo {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
          System.out.println(“1”);
    }
}

方式二:实现Runnable接口的方式

创建实现Runnable接口的类,实现run()方法,创建对象,以此对象作为参数传入Thread类的构造器中,调用Thread类的start()方法。

对比Thread优点:Thread是继承的形式,一个类只能继承一个类,所以Runnable接口实现的方式更灵活。

public class RunnableDemo {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnableThread());
        t.start();
    }
}
class MyRunnableThread implements Runnable {

    @Override
    public void run() {
        System.out.println("abc");
    }
}

方式三:实现Callable接口

实现Callable接口,传入FutureTask构造器创建对象,把FutureTask构造器对象传入Thread构造器,调用Thread类的start()方法。

FutureTask是Futrue接口的唯一的实现类,Future接口可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等。

对比Runnable优点:
Callable功能更强大些,实现的call()方法相比run()方法,可以返回值方法,可以抛出异常,支持泛型的返回值。

public class CallableDemo {
    public static void main(String[] args) {
        MyCallableThread myCallableThread = new MyCallableThread();
        FutureTask<String> futureTask = new FutureTask<>(myCallableThread);
        new Thread(futureTask).start();
        try {
            String result = futureTask.get();
            System.out.println("result:" + result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class MyCallableThread implements Callable {
    @Override
    public String call() throws Exception {
        System.out.println("callable > call");
        return "hello";
    }
}

锁的类型

取锁方式分类:悲观锁、乐观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,通常用于写多的场景。
典型代表为Java中的synchronized、ReentrantLock等独占锁。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,大部分情况下不需要上锁,通常用于读多的场景。
典型代表为CAS机制,java.util.concurrent.atomic包下面的原子变量类使用CAS实现。

锁的性质分类:不可重入锁、可重入锁、共享锁、排它锁

不可重入锁

【例】无。(Java提供的都是可重入锁,不可重入锁非常容易导致死锁。)
只判断这个锁有没有被锁上,只要被锁上申请锁的线程都会被要求等待,实现简单。

可重入锁

【例】ReentrantLock、ReentrantReadWriteLock
可重入锁:不仅判断锁有没有被锁上,还会判断锁是谁锁上的,当就是自己锁上的时候,那么他依旧可以再次访问临界资源,并把加锁次数加一(计数用于正确解锁)。
Java提供的锁都是可重入锁。不可重入锁非常容易导致死锁。

共享锁

【例】ReentrantReadWriteLock
线程可以同时获取锁。ReentrantReadWriteLock对于读锁是共享的。在读多写少的情况下使用共享锁会非常高效

排它锁

【例】ReentrantLock
多线程不可同时获取的锁,与共享锁对立。与重入锁不矛盾可以是并存属性。

取锁时是否先参与排队分类:公平锁、非公平锁

公平锁

【例】ReentrantLock(boolean fair)可以配置为公平锁
线程试图获取锁时,先按尝试获取锁的时间顺序排队

非公平锁

【例】ReentrantLock默认是非公平锁
线程试图获取锁时,如果当前锁没有线程占有,则跟排队获取锁的线程一起竞争锁而无序按顺序排队,则为非公平锁。如果竞选失败,依然要排队。

根据锁的状态划分:偏向锁、轻量级锁、重量级锁

偏向锁

一段同步代码一直被一个线程所访问,那么该线程会自动获取锁

轻量级锁

当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能

重量级锁

当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

根据锁粒度划分:分段锁等

分段锁

【例】jdk8的ConcurrentHashMap实现方式
分段锁是一种锁思想,对数据分段加锁已提高并发效率,比如jdk8之前的ConcurrentHashMap,jdk8后采用CAS+synchronized。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

常用的并发方案

Lock

Lock lock = new XxxLock();
lock.lock();
// work
lock.unlock();

Synchronized

使用方便,常用的解决方案,支持方法、静态方法、代码块的锁

1. 修饰实例方法(修饰静态方法一样用法)

public class Demo {
public synchronized void methodOne() { } 
}

2.修饰代码块
有时如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方法对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。
我们可以使用如下几种对象来作为锁的对象:

1)成员锁(锁的对象是变量)

public Object synMethod(Object a1) {
	synchronized(a1) { 
		// 操作 
	} 
}

2)成员锁(锁的对象是变量)

synchronized(this) { 
	for (int j = 0; j < 100; j++) {
 		i++; 
	}
}

3)当前类的 class 对象锁

synchronized(AccountingSync.class) {
 	for (int j = 0; j < 100; j++) {
 		i++;
	} 
}

实现原理:

在 Java 中,每个对象都会有一个 monitor 对象,使用synchronized关键字在编译后是在方法前增加monitor.enter指令,在方法退出和异常处插入monitor.exit指令,通过对一个对象监视器(monitor)进行获取从而达到只能一个线程访问。

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,所以每个类只有一个类锁,所以synchronized关键字可以传入class,对于静态方法,由于此时对象还未生成,所以只能采用类锁。

底层进阶:
操作系统本身并不支持 monitor 机制,monitor机制是由编程语言实现,java的monitor由JVM实现。操作系统中semaphore 信号量 和 mutex 互斥量是最重要的同步原语,monitor是对semaphore 和mutex 的进一步封装,提供简洁易用的接口。

Volatile

volatile 只能保证可见性而不能保证原子性(准确的说是不能保证复合操作的原子性),要非常小心的使用才能确保线程安全,通常在某些特定场合下使用,如双重检查锁模式(常用于单例模式或延迟赋值的场景)

(volatile可使线程每次读取变量时都从主内存中读取,保证变量对于各线程的可见性,但是对于多CPU的计算机,CPU中有一层高速缓存——寄存器,volatile 不能保证其它 cpu 的缓存同步刷新,因此无法保证原子性。)

双重检查锁模式:

public class Singleton {
    private volatile static Singleton uniqueSingleton; // 1. 为变量添加volatile修饰符

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (null == uniqueSingleton) { //2. 第一重检查
            synchronized (Singleton.class) { // 3. synchronized加锁
                if (null == uniqueSingleton) { // 4. 第二重检查
                    uniqueSingleton = new Singleton();
                }
            }
        }
        return uniqueSingleton;
    }
}

CAS (Compare And Set)

java.util.concurrent.automic包中实现

CAS是最常见的乐观锁之一,应用于小概率需要锁资源的场景,常用于高并发的“查询并修改”的场景,通过“先查询判断再更新”的方式保证数据一致性。
场景例:
现有变量余额money=100,线程1需要扣款20元,线程2需要扣款30元,执行顺序如图:

业务上最终应该money=50,但由于并发问题最终money=70,在CAS方案中,在修改前需要先进行判断,对应思路的伪代码如下

Get money = 100
If(money == 100) {
	Set money = money -100
}

显然实际使用中并不能如上简单实现,因为以上操作并非原子操作,实际的Java代码需要调用Unsafe类的compareAndSwap系列方法,如compareAndSwapInt。该方法实际通过JNI调用底层C语言实现,最终CPU指令 cmpxchg,通过CPU实现原子性及“查询并修改”。

compareAndSet只会返回成功或失败,CAS的常规使用示例:

public final int incrementAndGet() {
    while(true) {
        //获取当前值
        int current = get();
        //设置期望值
        int next = current + 1;
        //调用Native方法compareAndSet,执行CAS操作
        if (compareAndSet(current, next))
            //成功后才会返回期望值,否则无线循环
            return next;
    }
}

从底层来说CAS也是有排他锁,但是相对synchronized的排他时间短非常短,在多线程情况下性能会比较好。

CAS使用注意事项:
CAS当需要等待获取锁时会自旋等待(即while true),非常消耗CPU资源,所以避免在高频取锁的场景中使用CAS。

ReentrantLock底层原理AQS

ReentrantLock通过AQS(AbstractQueuedSynchronizer)实现。AQS底层是通过CLH双向队列实现。

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

它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

ThreadLocal

通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。

内部结构

ThreadLocal提供四个方法

  1. public T get() { }
  2. public void set(T value) { }
  3. public void remove() { }
  4. protected T initialValue(){ }

内部是通过ThreadLocalMap实现

使用注意事项

1)脏数据

线程复用会造成脏数据。由于线程池会复用Thread对象,因此Thread类的成员变量threadLocals也会被复用。如果在线程的run()方法中不显式调用remove()清理与线程相关的ThreadLocal信息,并且下一个线程不调用set()设置初始值,就可能get()到上个线程设置的值

2)内存泄露

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏

大白话一点,ThreadLocalMap的key是弱引用,GC时会被回收掉,那么就有可能存在ThreadLocalMap的情况,这个Object就是泄露的对象。

其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。

解决办法

解决以上两个问题的办法很简单,就是在每次用完ThreadLocal后,及时调用remove()方法清理即可。

Java内存模型(JMM)

JMM(Java Memory Model)是一个抽象的概念,JMM是和多线程相关的,定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

Java内存模型中分为主内存和各线程的本地内存,共享变量存储于主内存中,各线程操作共享变量前先把变量复制一份副本到本地内存,线程对变量副本运算完后再刷新到主内存。

JMM三大特性:

  1. 可见性
  2. 原子性
  3. 有序性

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存;
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存;
  3. 加锁解锁是同一把锁;

(区别概念“JVM内存结构”,JVM内存结构描述的是Java程序执行过程中,由JVM管理的不同数据区域,如虚拟机栈、Java堆,本地方法栈等, 各个区域有其特定的功能。)

如何保证变量的可见性

主要实现可见性的方式有三种:

  • volatile,注意一点 volatile不能保证操作的原子性
  • Synchronized,synchronized互斥锁对应的内存间交互操作为lock和unlock。在对一个变量进行unlock操作之前,必须把变量值同步回主内存。
  • final,被final关键字修饰的变量在构造器中一旦初始化完成,并且没有发生 this 逃逸(其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final字段的值。

内存屏障

大多数现代计算机为了提高性能而采取乱序执行,内存屏障是一个指令级别的同步点,有内存屏障的地方,会禁止指令重排序。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

内存屏障用于解决可见性及有序性问题,内存屏障通过防止指令重排保证有序性,通过内存屏障前后的刷新主存保证可见性(可见安全)。

Volatile、Lock、synchronized、final都是通过内存屏障实现。

  • lock:解锁时,jvm会强制刷新cpu缓存,导致当前线程更改,对其他线程可见。
  • volatile:标记volatile的字段,每次读取都是直接读内存。
  • final:即时编译器在final写操作后,会插入内存屏障,来禁止重排序,保证可见性

Happen-Before原则

happen before的含义是指操作对后续的操作都是可见的,比如 A happen before B 的意思并不是说 A 操作发生在 B 操作之前,而是说 A 操作对于 B 操作一定是可见的。

happen before原则是JMM中重要的一个原则,主要用于明确有序性。

Java的happen before原则规定了八种规则,以明确有序性的满足条件,相反在这八种规则以外意味着不能确定其执行顺序。八种规则如下:

  • 程序次序规则:在一个线程内,按照代码执行,书写在前面的操作先行发生于书写在后面的操作。
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()方法返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

如对于一段代码添加了synchronized字段,各个线程在执行这段代码时需要获取锁,那么符合"管理锁定规则",可以确保其执行顺序,所以代码段中的变量可以不添加volatile关键字。

线程池

创建线程池

Java中已经提供了创建线程池的一个类:Executor,我们创建时,一般使用它的子类:ThreadPoolExecutor.

public ThreadPoolExecutor(int corePoolSize,  
                              int maximumPoolSize,  
                              long keepAliveTime,  
                              TimeUnit unit,  
                              BlockingQueue<Runnable> workQueue,  
                              ThreadFactory threadFactory,  
                              RejectedExecutionHandler handler)

  • corePoolSize就是线程池中的核心线程数量,这几个核心线程,即使在没有用的时候,也不会被回收。

  • maximumPoolSize就是线程池中可以容纳的最大线程的数量。
    很多人以为它的作用是这样的:”当线程池中的任务数超过 corePoolSize 后,线程池会继续创建线程,直到线程池中的线程数小于maximumPoolSize“,其实这种理解是完全错误的。它真正的作用是:当线程池中的线程数等于 corePoolSize 并且 workQueue 已满,这时就要看当前线程数是否大于 maximumPoolSize,如果小于maximumPoolSize 定义的值,则会继续创建线程去执行任务, 否则将会调用相应的任务拒绝策略来拒绝这个任务。

  • keepAliveTime,就是线程池中除了核心线程之外的其他的最长可以保留的时间。
    除了核心线程即使在无任务的情况下也不能被清除,其余的都是有存活时间的,keepAliveTime意思就是非核心线程可以保留的最长的空闲时间。
    当ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true时,keepAliveTime同样会作用于非核心线程。

  • util,就是计算这个时间的一个单位,共7种取值:

    • TimeUnit.DAYS; //天
    • TimeUnit.HOURS; //小时
    • TimeUnit.MINUTES; //分钟
    • TimeUnit.SECONDS; //秒
    • TimeUnit.MILLISECONDS; //毫秒
    • TimeUnit.MICROSECONDS; //微妙
    • TimeUnit.NANOSECONDS; //纳秒
  • workQueue,就是等待队列,任务可以储存在任务队列中等待被执行,执行的是FIFIO原则(先进先出)。

    • ArrayBlockingQueue   //基于数组的先进先出队列,此队列创建时必须指定大小;
    • LinkedBlockingQueue //基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
    • synchronousQueue  //这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。
  • threadFactory,线程工厂,用来为线程池创建线程,当我们不指定线程工厂时,线程池内部会调用 Executors.defaultThreadFactory()创建默认的线程工厂,其后续创建的线程优先级都是 Thread.NORM_PRIORITY。如果我们指定线程工厂,我们可以对产生的线程进行一定的操作。

  • rejectHandler,拒绝执行策略

    • ThreadPoolExecutor.AbortPolicy: // 丢弃任务并抛出RejectedExecutionException异常。
    • ThreadPoolExecutor.DiscardPolicy: // 也是丢弃任务,但是不抛出异常。
    • ThreadPoolExecutor.DiscardOldestPolicy:// 丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
    • ThreadPoolExecutor.CallerRunsPolicy:// 由调用线程处理该任务

线程池的创建方式

java.util.concurrent包下的Executors提供四种线程池:

  • NewCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  • NewFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • NewScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
  • NewSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 new ThreadPoolExecutor 实例的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致OOM。

怎么设置CPU最佳线程数

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

线程等待时间(非CPU运行时间,比如IO)所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

例如proxy代理应用的线程数量可以开到很大,因为本身不占用太多CPU运算。
例如解码等应用的线程数量只能与CPU核数相近,因为解码需要大量CPU运算。

线程状态

  • 新建状态(New): 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
  • 就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
  • 运行状态(Running): 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
  • 阻塞状态(Blocked): 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
  • 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。
  • 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
  • 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  • 死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

yield()方法

yield()方法只是提出申请释放CPU资源,至于能否成功释放由JVM决定。由于这个特性,一般编程中用不到此方法,但在很多并发工具包中,yield()方法被使用,如AQS、ConcurrentHashMap、FutureTask等。

join()方法

thread.Join把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。
比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。
例如在main中调用线程t的t.join,则会等待t方法执行完后再执行main的方法。

t.join();      // 调用join方法,等待线程t执行完毕
t.join(1000);  // 等待 t 线程,等待时间是1000毫秒。

notify/notifyAll()与wait()

wait()和notify()都是定义在Object类中,为什么如此设计。因为synchronized中的这把锁可以是任意对象,所以任意对象都可以调用wait()和notify(),并且只有同一把锁才能对线程进行操作,不同锁之间是不可以相互操作的,所以wait和notify属于Object。

wait、notify要放在sychronized同步块中,否则会抛出IllegalMonitorStateException。如果不在同步块中,调用this.wait()时当前线程都没有取得对象的锁,又谈何让对象通知线程释放锁、或者来竞争锁呢?如果确实不放到同步块中,则会产生 Lost-wake的问题,即丢失唤醒。

调用wait方法可以让当前线程进入等待唤醒状态,该线程会处于等待唤醒状态直到另一个线程调用了object对象的notify方法或者notifyAll方法。

notify()唤醒等待的线程,如果监视器种只有一个等待线程,使用notify()可以唤醒。但是如果有多条线程notify()是随机唤醒其中一条线程,与之对应的就是notifyAll()就是唤醒所有等待的线程。

线程间通信的几种方式

方式一:使用 volatile 关键字

大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。

方式二:使用Object类的wait() 和 notify() 方法
Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。(注意必须作用于synchronized中。可以简单理解lock不能锁对象,而wait、notify是对象的方法,所以是要配合synchronized使用)

方式三:使用JUC工具类 CountDownLatch
CountDownLatch***基于AQS框架,相当于也是维护了一个线程间共享变量state

方式四:使用 ReentrantLock 结合 Condition
显然这种方式使用起来并不是很好,代码编写复杂,而且线程B在被A唤醒之后由于没有获取锁还是不能立即执行,也就是说,A在唤醒操作之后,并不释放锁。这种方法跟 Object 的 wait() 和 notify() 一样。

方式五:基本LockSupport实现线程间的阻塞和唤醒
LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。

如何控制多线程执行顺序

方式一 join方法

public static void main(String[] args) {
	thread1.start();
	thread1.join();
	thred2.start();
	thread2.join();
	thread3.start();
}

join方法的底层是调用对象的wait方法,wait方法的意思是阻塞当前线程,而此处的当前线程并非指thread1子线程本身,而是调用thread1.join()的主线程。所以当在主线程调用thread1.join()时,主线程阻塞,等待thread1执行完毕后继续执行thread2的任务,实现顺序执行。

拓展:注意join方法是使调用者当前的线程阻塞,所以可以实现线程嵌套,如先创建threadA,然后在threadA中再运行threadB并调用threadB.join()方法时,是阻塞threadA,让threadB执行完毕。

方式二 Excutors.newSingleThreadExecutor()

ExecutorService executor = Excutors.newSingleThreadExecutor();
executor.execute(thread1);
executor.execute(thread2);
executor.execute(thread3);

newSingleThreadExecutor是单线程运行无限队列的线程池,所以每个时间段只有一个线程可以运行,而后续加入的线程将进入队列排队,从而实现顺序执行。

并行与并发的区别

理解一
并发是对需求侧的描述,并行才是对实现侧的描述,这两根本不是同一个范畴的东西,更不可能是互斥的关系。

举个栗子:

每天中午12:00一大波人来到食堂门口,这是并发访问(需求场景)。

然后食堂开了12个打菜窗口给来吃饭的人打菜,这是并行处理(实现方式)。

即使开了12个窗口,也不能同时满足几千人,所以大家要排队(实现方式)。

所以正确的描述上述场景的句子应该是:“食堂每天中午会收到大量并发访问的请求,于是食堂通过开12个窗口的方式并行地处理这些请求,即便如此,仍然无法同时满足所有的请求,所以食堂仍然要求大家排队等待”。

你看,不管是并发,还是并行,还是排队,在上述场景里是同时存在的,其实并不互斥。

理解二

并行与并发不是一个维度的概念,并行是指多个节点能同时进行的能力或场景,并发是指在一个节点中同时发生的场景。

例如在互联网架构中,一个服务可以部署多个节点的集群,同一时刻每个服务都在处理任务,这是并行的状态。如果有大量请求落到一个节点中,则该节点会出现并发场景。

协程与线程的区别

线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制,一个进程可轻松创建数十万计的协程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
(在python的爬虫、go语言中使用协程的频率较高)


[目录]《Java八股文纯享版——目录》

[上一篇]《Java八股文纯享版——篇①:Java基础》


posted @ 2022-09-12 22:22  leapMie  阅读(33)  评论(0编辑  收藏  举报