大数据开发面试——多线程

一、并发编程

1.并发编程的优缺点

1.1 优点
充分利用多核CPU的计算能力,通过并发编程的形式将多核CPU的计算能力发挥到极致,性能得到提升。
方面进行业务的拆分。提高系统并发能力和性能:高并发系统的开发,并发编程会显得尤为重要,利用好多线程机制可以大大提高系统的并发能力及性能;面对复杂的业务模型,并行程序会比串行程序更适应业务需求,而并发编程更适合这种业务拆分。cai
1.2 缺点
并发编程的目的是为了提高程序的执行效率,提高程序运行速度,但并发编程并不是总能提高性能,有时还会遇到很多问题,例如:内存泄漏,线程安全,死锁等。

2 并发编程的三要素

并发编程的三要素:(也是带来线程安全所在)
原子性:原子是不可再分割的最小单元,原子性是指一个或多个操作要么全部执行成功,要么全部执行失败。
可见性:一个线程对共享变量的修改,另一个线程能看到(synchronized,volatile)
有序性:程序的执行顺序按照代码的先后顺序

2.1线程安全的问题及解决方案
(1)线程切换带来的原子性问题————JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
(2) 缓存导致的可见性问题————synchronized、volatile、LOCK,可以解决可见性问题
(3) 编译优化带来的有序性问题————Happens-Before 规则可以解决有序性问题

2.2原子操作
原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间切换到另一个线程。
java中的原子操作介绍:jdk1.5的包为java.util.concurrent.atomic

这个包里面提供了一组原子类。其基本特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性。

即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,但不会阻塞线程(synchronized 会把别的等待的线程挂,或者说只是在硬件级别上阻塞了)。

其中的类可以分成4组

AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
AtomicIntegerArray,AtomicLongArray
AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
AtomicMarkableReference,AtomicStampedReference,AtomicReferenceArray
Atomic类的作用

使得让对单一数据的操作,实现了原子化
使用Atomic类构建复杂的,无需阻塞的代码
访问对2个或2个以上的atomic变量(或者对单个atomic变量进行2次或2次以上的操作)通常认为是需要同步的,以达到让这些操作能被作为一个原子单元。
AtomicBoolean , AtomicInteger, AtomicLong, AtomicReference 这四种基本类型用来处理布尔,整数,长整数,对象四种数据。

构造函数(两个构造函数)

默认的构造函数:初始化的数据分别是false,0,0,null

带参构造函数:参数为初始化的数据

set( )和get( )方法:可以原子地设定和获取atomic的数据。类似于volatile,保证数据会在主存中设置或读取

getAndSet( )方法

原子的将变量设定为新数据,同时返回先前的旧数据
其本质是get( )操作,然后做set( )操作。尽管这2个操作都是atomic,但是他们合并在一起的时候,就不是atomic。在Java的源程序的级别上,如果不依赖synchronized的机制来完成这个工作,是不可能的。只有依靠native方法才可以。
compareAndSet( ) 和weakCompareAndSet( )方法

这两个方法都是conditional modifier方法。这2个方法接受2个参数,一个是期望数据(expected),一个是新数据(new);如果atomic里面的数据和期望数据一致,则将新数据设定给atomic的数据,返回true,表明成功;否则就不设定,并返回false。
对于AtomicInteger、AtomicLong还提供了一些特别的方法。getAndIncrement( )、incrementAndGet( )、getAndDecrement( )、decrementAndGet ( )、addAndGet( )、getAndAdd( )以实现一些加法,减法原子操作。(注意 --i、++i不是原子操作,其中包含有3个操作步骤:第一步,读取i;第二步,加1或减1;第三步:写回内存)

例子-使用AtomicReference创建线程安全的堆栈

public class LinkedStack<T> {
     private AtomicReference<Node<T>> stacks = new AtomicReference<Node<T>>();

     public T push(T e) {
          Node<T> oldNode, newNode;
          while (true) { //这里的处理非常的特别,也是必须如此的。
               oldNode = stacks.get();
               newNode = new Node<T>(e, oldNode);
               if (stacks.compareAndSet(oldNode, newNode)) {
                    return e;
               }
          }
     }

     public T pop() {
          Node<T> oldNode, newNode;
          while (true) {
               oldNode = stacks.get();
               newNode = oldNode.next;
               if (stacks.compareAndSet(oldNode, newNode)) {
                    return oldNode.object;
               }
          }
     }

     private static final class Node<T> {
          private T object;
          private Node<T> next;

          private Node(T object, Node<T> next) {
               this.object = object;
               this.next = next;
          }
     }
}

2.3并发和并行有和区别
并发:多个任务在同一个CPU上,按照细分的时间片轮流交替执行,由于时间很短,看上去好像是同时进行的。
并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。
串行:有n个任务,由一个线程按照顺序执行。

2.4什么是多线程,多线程的优劣
定义:多线程是指程序中包含多个流,即在一个程序中可以同时进行多个不同的线程来执行不同的任务
优点:

可以提高CPU的利用率,在多线程中,一个线程必须等待的时候,CPU可以运行其它线程而不是等待,这样就大大提高了程序的效率,也就是说单个程序可以创建多个不同的线程来完成各自的任务。
缺点:
线程也是程序,线程也需要占内存,线程也多内存也占的也多。
多线程需要协调和管理,所以需要CPU跟踪线程。
线程之间共享资源的访问会相互影响,必须解决禁用共享资源的问题。

3.线程与进程

3.1什么是线程与进程
进程:进程是程序的一次执行过程,是程序在执行过程中分配和管理资源的基本单位,每个进程都有自己独立的一块内存空间。
线程:线程是CPU调度和分派的基本单位,它可以和同一进程下的其他线程共享全部资源。

3.2线程与进程的区别
联系
线程是进程的一部分,一个进程可以有多个线程,但线程只能存在于一个进程中
区别
根本区别:进程是操作系统的基本单位,线城是任务的调度执行的基本单位
开销及共享:进程都有自己的地址空间资源,共享复杂,进程间切换开销较大(需要进程间通信)同步简单;线程共享所属进程的资源,线程也有自己的运行栈和程序计数器,线程间的切换开销较小,共享简单,但是同步复杂,需要加锁。
影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存于应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行。
3.3线程的状态
https://segmentfault.com/a/1190000016197831
https://www.iamshuaidi.com/1058.html
1.线程的状态
线程状态

  • New(新建)
  • Runnable(可运行)
  • Blocked(被阻塞)
  • Waiting(等待)
  • Timed waiting(计时等待)
  • Terminated(被终止)
  1. 新建:new Thread()后线程的状态就是新建。此时只是被构建还没有调用start()方法
  2. 可运行:线程一旦调用start()方法,无论是否运行,状态都为Runable,注意Runable状态指示表示线程可以运行(包含就绪和运行两种状态),不表示线程当下一定在运行,线程是否运行由虚拟机所在操作系统调度决定。
  3. 被阻塞:线程试图获取一个内部对象的Monitor(进入synchronized方法或synchronized块)但是其他线程已经抢先获取,那此线程被阻塞,知道其他线程释放Monitor并且线程调度器允许当前线程获取到Monitor,此线程就恢复到可运行状态。(表示线程阻塞与锁)
  4. 等待:当一个线程等待另一个线程通知调度器一个条件时,线程进入等待状态。(当前线程需要等待其他线程通知或中断)
  5. 计时等待:和等待类似,某些造成等待的方法会允许传入超时参数,这类方法会造成计时等待,收到其他线程的通知或者超时都会恢复到可运行状态。
  6. 被终止:线程执行完毕正常结束或执行过程中因未捕获异常意外终止都会是线程进入被终止状态。
    2.线程间的转换
    在这里插入图片描述
    观察状态转化图,我们发现“可运行”状态为所有状态的必经状态。我们分析出四条基本的状态转换线路图。
  • 新建—>可运行—>被终止
  • 新建—>可运行—>被阻塞—>可运行—>被终止
  • 新建—>可运行—>等待—>可运行—>被终止
  • 新建—>可运行—>计时等待—>可运行—>被终止
    “新建”和“被终止”状态分别为起始和结束状态,和“可运行”状态不可逆。其他状态均能和“可运行”状态相互转换。
    在这里插入图片描述

3.3 用户线程与守护线程
用户(User)线程:运行在前台,执行具体任务,如程序的主线程,连接网络的子线程都是用户线程。
守护(Daemon)线程:运行在后台,为其它前台线程服务,也可以说守护线程是JVM非守护线程的”佣人“,一旦所有线程都执行结束,守护线程会随着JVM一起结束运行。
main函数就是一个用户线程,main函数启动时,同时JVM还启动了好多的守护线程,如垃圾回收线程,比较明显的区别时,用户线程结束,JVM退出,不管这个时候有没有守护线程的运行,都不会影响JVM的退出。

3.4 什么是线程死锁
死锁是指两个或两个以上进程(线程)在执行过程中,由于竞争资源或由于彼此通信造成的一种堵塞的现象,若无外力的作用下,都将无法推进,此时的系统处于死锁状态。
如图,线程A拥有的资源2,线程B拥有的资源1,此时线程A和线程B都试图去拥有资源1和资源2,但是它们的🔒还在,因此就出现了死锁。
线程死锁
3.5 形成死锁的四个必要条件
互斥条件:线程(进程)对所分配的资源具有排它性,即一个资源只能被一个进程占用,直到该进程被释放。
请求与保持条件:一个进程(线程)因请求被占有资源而发生堵塞时,对已获取的资源保持不放。
不剥夺条件:线程(进程)已获取的资源在未使用完之前不能被其他线程强行剥夺,只有等自己使用完才释放资源。
循环等待条件:当发生死锁时,所等待的线程(进程)必定形成一个环路,死循环造成永久堵塞。

3.6 如何避免死锁
我们只需破坏形参死锁的四个必要条件之一即可。
破坏互斥条件:无法破坏,我们的🔒本身就是来个线程(进程)来产生互斥
破坏请求与保持条件:一次申请所有资源
破坏不剥夺条件:占有部分资源的线程尝试申请其它资源,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件:按序来申请资源。

3.7 什么是上下文的切换
当前任务执行完,CPU时间片切换到另一个任务之前会保存自己的状态,以便下次再切换会这个任务时可以继续执行下去,任务从保存到再加载执行就是一次上下文切换。

4.创建线程

4.1创建线程的四种方式

  • 继承Thread类
    (1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
    (2)创建Thread子类的实例,即创建了线程对象。
    (3)调用线程对象的start()方法来启动该线程。
    Thread.currentThread()方法返回当前正在执行的线程对象。GetName()方法返回调用该方法的线程的名字
public class MyThread extends Thread{
     @Override
      public void run(){
           System.out.println(Thread.currentThread().getName()
                +  " " + i);
}
      Public static void main(String[] args){
              New MyThread().start();
}
}

使用继承Thread类的方法来创建线程时,多个线程之间无法共享线程类的实例变量。

  • 实现Runnable接口

(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
(2)创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
(3)调用线程对象的start()方法来启动该线程。

public class MyThread implements Runnable{
    @Override
     public void run(){
           System.out.println(Thread.currentThread().getName()
                +  " " + i);
}
     public static void main(String[] args){
          New Thread(new MyThread().start());

}
}
  • 通过 Callable接口 和 Future 创建线程
    (1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
    (2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
    (3)使用FutureTask对象作为Thread对象的target创建并启动新线程。
    (4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
callableThreadTest ctt = new CallableThreadTest();
FutureTask<Integer> ft = new FutureTask<>(ctt);
  • Executors工具类创建线程池
    此处用 JDK 自带的 Executors 来创建线程池对象。
  • 首先,定一个 Runnable 的实现类,重写 run 方法。
  • 然后创建一个拥有固定线程数的线程池。
  • 最后通过 ExecutorService 对象的 execute 方法传入线程对象。
1.提供指定线程数量的线程池
ExecutorService executorService = Executors.newFixedThreadPool(n); 创建一个可重用固定线程数的线程池 

2.执行指定的线程操作
executorService.execute(new NumberThread()); //适用于Runnable
executorService.submit(new  NumberThread());//适用于Callable,可用future获取结果,然后get得到值

3.关闭线程池
executorService.shutdown();

5.线程池

https://segmentfault.com/a/1190000037589073
1.线程池的创建
(1) Executors.newFixedThreadPool(n); 创建一个可重用固定线程数的线程池

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
  • corePoolSize与maximumPoolSize相等,即其线程全为核心线程,是一个固定大小的线程池,是其优势;
  • keepAliveTime = 0 该参数默认对核心线程无效,而FixedThreadPool全部为核心线程;
  • workQueue 为LinkedBlockingQueue(无界阻塞队列),队列最大值为Integer.MAX_VALUE。如果任务提交速度持续大余任务处理速度,会造成队列大量阻塞。因为队列很大,很有可能在拒绝策略前,内存溢出。是其劣势;
  • FixedThreadPool的任务执行是无序的;

它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是在线程池空闲时,即线程池中没有可运行任务时,它也不会释放工作线程,还会占用一定的系统资源

适用场景:可用于Web服务瞬时削峰,但需注意长时间持续高峰情况造成的队列阻塞。

(2)Executor.newCachedThreadPool();创建一个可根据需要创建线程的线程池

     public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
  • corePoolSize = 0,maximumPoolSize = Integer.MAX_VALUE,即线程数量几乎无限制;
  • keepAliveTime = 60s,线程空闲60s后自动结束。
  • workQueue 为 SynchronousQueue 同步队列,这个队列类似于一个接力棒,入队出队必须同时传递,因为CachedThreadPool线程创建无限制,不会有队列等待,所以使用SynchronousQueue;

适用场景:快速处理大量耗时较短的任务,如Netty的NIO接受请求时,可使用CachedThreadPool。

(3)Executor.newSingleThreadPool();创建一个只有一个线程的线程池

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先进先出的顺序执行队列中的任务。
https://www.iamshuaidi.com/1131.html
(4)Executor.newScheduledThreadPool();创建一个线程池,他可安排在给定延迟后运行命令或者定期地执行

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

newScheduledThreadPool调用的是ScheduledThreadPoolExecutor的构造方法,而ScheduledThreadPoolExecutor继承了ThreadPoolExecutor,构造是还是调用了其父类的构造方法。
通过 ThreadPoolExecutor 的构造方法实现:
在这里插入图片描述

2.线程池的ThreadPoolExecutor构造参数
(1)参数详解

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

https://www.iamshuaidi.com/1129.html
1、 corePoolSize(线程池的基本大小):当提交一个任务到线程池时,如果当前 poolSize < corePoolSize 时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads() 方法,线程池会提前创建并启动所有基本线程。

2、 maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如果使用了无界的任务队列这个参数就没什么效果。

3、 keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以,如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。

4、 TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS,千分之一微秒)。

5、 workQueue(任务队列):用于保存等待执行的任务的阻塞队列。

可以选择以下几个阻塞队列:

1)、 ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。

2)、LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool() 使用了这个队列。

3)、SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列。

4)、 PriorityBlockingQueue:一个具有优先级的无限阻塞队列。

6、 threadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字。

7、RejectExecutionHandler(饱和策略):队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是 AbortPolicy,表示无法处理新任务时抛出异常。
饱和策略:

在 JDK1.5 中 Java 线程池框架提供了以下 4 种策略:

AbortPolicy:直接抛出异常。
CallerRunsPolicy:只用调用者所在线程来运行任务。

DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。

DiscardPolicy:不处理,丢弃掉。

当然,也可以根据应用场景需要来实现RejectedExecutionHandler 接口自定义策略。如记录日志或持久化存储不能处理的任务。
在这里插入图片描述

(2)参数使用
ThreadPoolExecutor为线程池的实现类,提供了线程池的维护操作等相关方法,继承自AbstractExecutorService,AbstractExecutorService实现了ExecutorService接口
ThreadPoolExecutor service = (ThreadPoolExecutor) executorService;
Service.setCorePoolSize(15);

6.线程间通信

https://zhuanlan.zhihu.com/p/138689342
1、为什么需要线程通信
线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步的执行,但是如果每个线程间都孤立的运行,那就会造资源浪费。所以在现实中,我们需要这些线程间可以按照指定的规则共同完成一件任务,所以这些线程之间就需要互相协调,这个过程被称为线程的通信。
线程的通信可以被定义为:线程通信就是当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。
2、线程通信的方式
线程通信主要可以分为三种方式,分别为共享内存、消息传递和管道流。每种方式有不同的方法来实现
(1)共享内存:线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来隐式通信。
volatile共享内存
(2)消息传递:线程之间没有公共的状态,线程之间必须通过明确的发送信息来显示的进行通信。
wait/notify等待通知方式
join方式
(3)管道流
管道输入/输出流的形式
2.1共享内存——volatile共享内存
volatile有一个关键的特性:保证内存可见性,即多个线程访问内存中的同一个被volatile关键字修饰的变量时,当某一个线程修改完该变量后,需要先将这个最新修改的值写回到主内存,从而保证下一个读取该变量的线程取得的就是主内存中该数据的最新值,这样就保证线程之间的透明性,便于线程通信。
2.2消息传递
2.2.1wait/notify等待通知方式
从字面上理解,等待通知机制就是将处于等待状态的线程将由其它线程发出通知后重新获取CPU资源,继续执行之前没有执行完的任务。
等待/通知机制提供了三个方法用于线程间的通信

wait()当前线程释放锁并进入等待(阻塞)状态notify()唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后继续竞争锁notifyAll()唤醒所有正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后继续竞争锁

等待/通知机制是指一个线程A调用了对象Object的wait()方法进入等待状态,而另一线程B调用了对象Object的notify()或者notifyAll()方法,当线程A收到通知后就可以从对象Object的wait()方法返回,进而执行后序的操作。线程间的通信需要对象Object来完成,对象中的wait()、notify()、notifyAll()方法就如同开关信号,用来完成等待方和通知方的交互。
NOTE:使用wait()、notify()和notifyAll()需要注意以下细节

使用wait()、notify()和notifyAll()需要先调用对象加锁
调用wait()方法后,线程状态由Running变成Waiting,并将当前线程放置到对象的等待队列
notify()和notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()和notifyAll()的线程释放锁之后等待线程才有机会从wait()返回
notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部转移到同步队列,被移到的线程状态由Waiting变为Blocked。
从wait()方法返回的前提是获得调用对象的锁

2.2.2join方式
在很多应用场景中存在这样一种情况,主线程创建并启动子线程后,如果子线程要进行很耗时的计算,那么主线程将比子线程先结束,但是主线程需要子线程的计算的结果来进行自己下一步的计算,这时主线程就需要等待子线程,java中提供可join()方法解决这个问题。

join()方法的作用是:在当前线程A调用线程B的join()方法后,会让当前线程A阻塞,直到线程B的逻辑执行完成,A线程才会解除阻塞,然后继续执行自己的业务逻辑,这样做可以节省计算机中资源。
NOTE:每个线程的终止的前提是前驱线程的终止,每个线程等待前驱线程终止后,才从join方法返回,实际上,这里涉及了等待/通知机制,即下一个线程的执行需要接受前驱线程结束的通知。

2.3管道输入/输出流
管道流是是一种使用比较少的线程间通信方式,管道输入/输出流和普通文件输入/输出流或者网络输出/输出流不同之处在于,它主要用于线程之间的数据传输,传输的媒介为管道。

管道输入/输出流主要包括4种具体的实现:PipedOutputStrean、PipedInputStrean、PipedReader和PipedWriter,前两种面向字节,后两种面向字符。

java的管道的输入和输出实际上使用的是一个循环缓冲数组来实现的,默认为1024,输入流从这个数组中读取数据,输出流从这个数组中写入数据,当这个缓冲数组已满的时候,输出流所在的线程就会被阻塞,当向这个缓冲数组为空时,输入流所在的线程就会被阻塞。

NOTE:对于Piped类型的流,必须先进性绑定,也就是调用connect()方法,如果没有将输入/输出流绑定起来,对于该流的访问将抛出异常。

7.线程同步

1.线程安全
2.线程同步的方式
使用 Synchronized 关键字;

wait 和 notify;

使用特殊域变量 volatile 实现线程同步;

使用可重入锁实现线程同步;

使用阻塞队列实现线程同步;

使用信号量 Semaphore。

8.锁

8.1 乐观锁&悲观锁
1.基本概念
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
(1)乐观锁,认为是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿书聚的时候都认为别人不会修改,因此不会上锁。但是在更新难过的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新,如果失败则要重复读-比较-写的操作)
实现依据的是版本号机制和CAS(compare and swap))算法

  • 版本号机制
    https://segmentfault.com/a/1190000016611415
    a. 实现流程
    版本号机制是在数据表中加上一个 version 字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1,当线程A要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

取出记录时,获取当前version
更新时,带上这个version
执行更新时, set version = newVersion where version = oldversion
如果version不对,就更新失败
b. 核心SQL

update table set name = 'Aron', version = version + 1 where id = #{id} and version = #{version};  
  • CAS算法
    https://segmentfault.com/a/1190000021653471
    a. 实现流程
    CAS 即 compare and swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)
    CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
    在这里插入图片描述
    如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值)。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。 ”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

b. CAS的伪代码

do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))

c. 缺点
https://segmentfault.com/a/1190000021653471

  • ABA问题
    **问题描述:**如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。
    问题解决:
    · JDK 1.5 以后的 AtomicStampedReference类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
    · 使用版本号机制,如手动增加版本号字段。即采用CAS的一个变种DCAS来解决这个问题。 DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。对于每个V,如果引用修改了一次,这个计数器就加1。然后再这个变量需要update的时候,就同时检查变量的值和计数器的值。

  • 循环时间长开销大
    **问题描述:**自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
    问题解决:
    · 破坏掉for死循环,当超过一定时间或者一定次数时,return退出。JDK8新增的LongAddr,和ConcurrentHashMap类似的方法。当多个线程竞争时,将粒度变小,将一个变量拆分为多个变量,达到多个线程访问多个资源的效果,最后再调用sum把它合起来。
    · 如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空,从而提高CPU的实行效率。

  • 只能保证一个共享变量的原子操作
    问题描述:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。
    问题解决:
    · 用锁
    · 把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ji=2a,然后用CAS来操作ij。
    · 封装成对象。注:从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之前的原子性,可以把多个变量放在一个对象里来进行CAS操作。

(2)悲观锁,就是悲观思想,即认为写多,遇到并发写的的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读些这个数据就会block直到拿到锁。
java中的悲观锁就是synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到才会转换为悲观锁,如RetrrenLock。MySQL的读锁、写锁、行锁等也是悲观锁的表现
a. synchronized
https://zhuanlan.zhihu.com/p/346028951
修饰实例方法
作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

public class SynchronizedTest {
    public static void main(String[] args) {
        doSynchronizedTest();
    }
    //通过synchronized修饰方法
    public static synchronized void doSynchronizedTest(){
        System.out.println("this is in synchronized");
    }
}

修饰静态方法
作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管 new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁;

       /**
        * 静态方法加锁
        */
       public synchronized static void testStaticSynchronized() {
           //对number加1操作
           number++;
           //打印(线程名 + number)
           System.out.println(Thread.currentThread().getName() + " -> 当前number为" + number);
       }
   }

类锁:当synchronized修饰一个static方法时,获取到的是类锁,作用于这个类的所有对象。
对象锁:当synchronized修饰一个非static方法时,获取到的是对象锁,作用于调用该方法的当前对象。

修饰代码块
指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。和 synchronized 方法一样,synchronized(this) 代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。这里再提一下:synchronized 关键字加到非 static 静态方法上是给对象实例上锁。另外需要注意的是:尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓冲功能。

public class SynchronizedTest {
    public static void main(String[] args) {
        //通过synchronized修饰代码块
        synchronized (SynchronizedTest.class) {
            System.out.println("this is in synchronized");
        }
    }
}

8.2 公平锁&非公平锁
(1) 基本定义
https://blog.csdn.net/qq_35190492/article/details/104943579
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
优点:所有的线程都能得到资源,不会饿死在队列中。
缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

(2) ReentrantLock实现公平锁与非公平锁
https://segmentfault.com/a/1190000039413070
公平锁

final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//判断当前同步队列没有前驱节点即是否有线程在等待(队列是否为空,队头是否有等待的线程)
    if (!hasQueuedPredecessors() &&
        compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current); //compareAndSetState(0, acquires)使用CAS修改同步状态变量
        return true;
    }
}

hasQueuedPredecessors的实现

public final boolean hasQueuedPredecessors() {
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

非公平锁

final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//没有判断是否有前驱节点在等待,直接CAS尝试获取锁
    if (compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
}

非公平锁和公平锁的两处不同: 1. 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。

非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。
公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。

相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。

8.3 可重入锁&不可重入锁
https://www.codenong.com/cs106601285/
可重入锁,指的是以线程为单位,同一线程外层函数获得锁之后,内层递归函数仍然有获取该的代码,但不受影响。(前提锁对象得是同一个对象或者class)
在JAVA环境下,ReentrantLock和synchronized都是可重入锁
可重入锁的一个优点是可一定程度避免死锁。

synchronized实现可重入锁

public class Demo2 {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

在这个类中两个方法都被synchronized所修饰,在doSomething()中调用doOthers()方法。因为synchronized锁是可重入的,所以在一个线程中调用doOthers()时就可以直接获得当前对象的锁,进入doOthers()。
如果是一个不可重入锁,在当前线程在doSomething()中调用doOthers时,因为doOthers()需要获取当前锁对象,需要都Something()释放掉当前锁,而实际上当前锁已经被当前线程所持有,自然是没办法释放,就造成死锁了。

ReentrantLock实现可重入锁
ReentrantLock继承父类AQS(AbstractQueuedSynchronizer),其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status ==0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
在这里插入图片描述
不可重入锁
线程获取锁后,内部不能再获取锁,由于之前已经获取过还没释放而阻塞,会导致线程死锁。
非可重入锁有NonReentrantLock。
NonReentrantLock继承父类AQS(AbstractQueuedSynchronizer),其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

8.4 无锁&偏向锁&轻量级锁&重量级锁
https://www.bilibili.com/video/BV1oK411V7vE?p=3
锁的状态总共有四种:无锁状态,偏向锁,轻量级锁和重量级锁
无锁

偏向锁
轻量级锁

重量级锁

  1. 互斥锁&共享锁

  2. 自旋锁&非自旋锁
    自旋
    https://blog.csdn.net/huangdong50/article/details/106869764
    所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。
    自旋其实就是在当前这个线程获取同步资源锁失败的时候,该线程会在原地一直等待锁释放,不会把该线程阻塞,只要获得锁的那个线程释放锁之后,这个等待的线程马上就可以去获得锁。原地循环等待会占用处理器时间的,类似在执行一个空的for循环一样。

自旋锁
是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
实现

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。

非自旋锁

3.synchronized同步锁
4.volitile
5.ReentrantLock
参考
1.https://blog.csdn.net/JAYU_37/article/details/106321844
2.https://www.ituring.com.cn/article/111835
3.https://zhuanlan.zhihu.com/p/269259722
4.https://www.cnblogs.com/guoyu1/p/12179244.html
5.https://www.huaweicloud.com/articles/7ce6f9f1125a2e406f17911498c4aa4f.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值