Java多线程面试题汇总

Java多线程面试笔记

文章目录

1. 基本概念问题

线程问题

为什么要使用并发编程(并发编程的优点)

  • 充分利用多核CPU的计算能力:通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升

  • 方便进行业务拆分,提升系统并发能力和性能:在特殊的业务场景下,先天的就适合于并发编程。现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。

并发编程有什么缺点

并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如**:内存泄漏、上下文切换、线程安全、死锁**等问题。

进程和线程的比较

概念:

进程 : 指的就是一个在内存中运行的应用程序。是并发执行的程序在运行过程中分配和管理资源的基本单位,它是一个动态概念,是竞争计算机资源的最小单位,每一个进程都有属于自己的独立的内存空间,例如在Window系统中运行的一个xx.exe就是一个进程。

线程 : 是进程的一个执行单元,它是比进程更小的独立运行的基本单位.线程也被称为轻量级进程.

总结来说,进程是资源分配的最小单位,而线程是程序运行的最小单位

为什么会有线程的概念?

答 : 我们说每一个进程都有自己的地址空间,即进程空间,在网络和多用户换机下,一个服务器通常需要接受大量不确定数量用户的并发请求,此时如果我们都为每一个请求创建一个进程显然是行不通的(系统开销很大,响应用户请求的效率也会大大降低),故操作系统引出了线程的概念.

进程和线程的区别
  • 进程是资源分配的最小单位,而线程是程序执行的最小单位
  • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表进行维护代码段,堆栈段和数据段,这种操作十分昂贵.而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进行小很多,同时创建一个线程的开销也比进程要小很多
  • 线程之间的通信更方便,同一个进程下的线程共享全局变量,静态变量等等数据,而进程之间的通信需要以通信的方式进行
  • 多进程程序更加地健壮,多线程程序只要其中一个线程死掉,该进程就GG;但是一个进行死掉并不会对其他进程造成影响(因为他们有自己独立的地址空间)
  • 每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

什么是多线程,多线程的优劣?

多线程:多线程是指程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务

多线程的好处:
  • 可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
多线程的劣势:
  • 线程也是程序,所以线程需要占用内存,线程越多占用内存也越多

  • 多线程需要协调和管理,所以需要 CPU 时间跟踪线程

  • 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。

什么是线程安全? 如何保证线程安全?

什么是线程安全?

《Java并发编程实践》中对于线程安全的定义如下:

当多个线程同时访问一个对象,如果不考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步操作,或者在调用方法时进行其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么我们说这个对象就是线程安全的.
多线程编程的三个核心概念
  • 原子性(这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)
  • 可见性(当多线程并发访问共享变量时,一个线程对共享变量的修改,其他线程能够立即看到)
  • 有序性(程序的执行顺序按照代码的先后顺序执行)

(important!)如何能够保证线程安全?

第一种方式 : 互斥同步
互斥同步的意思就是在多线程中,让一个线程进入监视器(Minitor),或者认为让一个线程进入一个单独的房间,其他线程必须等待,直到这个线程退出监视器(房间)为止
在实现互斥同步的方式中,最常用到的就是Synchronized关键字
Synchronized关键字实现同步的基础 : Java中的每一个对象都可以作为锁
具体表现在:
(1) 普通同步方式 锁的是当前实例对象
(2) 静态同步方式 锁的是当前类的Class对象
(3) 同步代码块 锁的是Synchronized括号内匹配的对象
PS:Synchronized(重量级锁)的实现原理

Synchronized 同步代码块:Synchronized关键字经过编译之后,会在同步代码块前后分别形成monitorentermonitorexit字节码指令,在执行monitorenter指令的时候,首先尝试获取对象的锁,如果这个锁没有被锁定或者当前线程已经拥有了那个对象的锁,锁的计数器就加1,在执行monitorexit指令时会将锁的计数器减1,当减为0的时候就释放锁。如果获取对象锁一直失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

同步方法

方法级的同步是隐式的,无须通过字节码指令来控制,JVM可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当方法调用的时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先持有monitor对象,然后才能执行方法,最后当方法执行完(无论是正常完成还是非正常完成)时释放monitor对象。在方法执行期间,执行线程持有了管程,其他线程都无法再次获取同一个管程。

总结 :

我们说monitorentermonitorexit指令是通过Monitor对象实现的,同时,Synchronized的实现不仅和Monitor对象有关,还和另外一个东西有关,那就是对象头.

对象头知识点

java万物皆对象,那么对象在内存中是如何存储的?

首先,我们说每个对象分为三块区域:

  • 对象头
  • 实例数据
  • 对齐填充

其中,我们具体讲解一下每一个区域的具体信息

  • 对象头包含两个部分 :
    • 一部分是Mark Word,用来存储对象自身的运行时数据,如哈希吗(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等等,这一部分占一个字节;
    • 第二部分是Klass Pointer(类型指针),是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,这部分也占一个字节(注意: 如果对象是数组类型的,则需要3个字节来存储对象头,因为还需要一个字节来存储数组的长度)
  • 实例数据存放的是类属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按照4字节存储
  • 对齐填充是因为虚拟机要求对象起始地址必须是8字节的整数倍.填充的数据不是必须存在的,仅仅是为了字节对齐.

在这里插入图片描述

从对象头的存储内容可以看出锁的状态都保存在对象头中,Synchronized也不例外,当其从轻量级锁膨胀为重量级锁时,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。

关于Synchronized的实现在java对象头里较为简单,只是改变一下标识位,并将指针指向monitor对象的起始地址,其实现的重点是monitor对象。

Monitor对象知识点

什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor中有几个关键属性:

  • _count用来记录该线程获取锁的次数
  • _WaitSet存放处于wait状态的线程队列
  • _EntryList存放处于等待获取锁block状态的线程队列,即被阻塞的线程
  • _owner指向持有ObjectMonitor对象的线程

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1,若线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:

在这里插入图片描述

Synchronized优化

早期,Synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁、轻量级锁和自旋锁等概念,接下来我们将简单了解一下Java官方在JVM层面对Synchronized锁的优化.

偏向锁

Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。

如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到

标准的轻量级锁。故它会先恢复为轻量级锁,而不是直接变成重量级锁

它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

偏向锁获取过程:
  • (1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态
  • (2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  • (3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  • (4)如果CAS获取偏向锁失败,则表示有竞争。当到达**全局安全点(safepoint)**时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
  • (5)执行同步代码
轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗

但是线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那线程也不能一直占用cpu自旋做无用功,所以需要设定一个自旋等待的最大时间

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点
  • 优点:自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
  • 缺点:但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,占着XX不XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁;
第二种方式 : 非阻塞同步(CAS操作)

因为使用synchronized的时候,只能有一个线程可以获取对象的锁,其他线程就会进入阻塞状态,阻塞状态就会引起线程的挂起和唤醒,会带来很大的性能问题,所以就出现了非阻塞同步的实现方法

  先进行操作,如果没有其他线程争用共享数据,那么操作就成功了,如果共享数据有争用,就采取补偿措施(不断地重试)。
  我们想想哈,互斥同步里实现了 操作的原子性(这个操作没有被中断) 和 可见性(对数据进行更改后,会立马写入到内存中,其他线程在使用到这个数据时,会获取到最新的数据),那怎么才能不用同步来实现原子性和可见性呢? 
  CAS是实现非阻塞同步的计算机指令,它有三个操作数:内存位置,旧的预期值,新值,在执行CAS操作时,当且仅当内存地址的值符合旧的预期值的时候,才会用新值来更新内存地址的值,否则就不执行更新。
  使用方法:使用JUC包下的整数原子类decompareAndSet()和getAndIncrement()方法
  缺点 :ABA 问题  版本号来解决
  只能保证一个变量的原子操作,解决办法:使用AtomicReference类来保证对象之间的原子性。可以把多个变量放在一个对象里。
第三种方式 : 无同步方案(ThreadLocal)

线程本地存储:将共享数据的可见范围限制在一个线程中。这样无需同步也能保证线程之间不出现数据争用问题。经常使用的就是ThreadLocal类.

最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理

  public T get() { }  get()方法是用来获取ThreadLocal在当前线程中保存的变量副本
  public void set(T value) { }  set()用来设置当前线程中变量的副本
  public void remove() { }  remove()用来移除当前线程中变量的副本
  protected T initialValue() { }  initialValue()是一个protected方法,一般是用来在使用时进行重写的,它是一个延迟加载方法

总结来说:

  • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
  • synchronized、volatile、LOCK,可以解决可见性问题
  • Happens-Before 规则,volatile可以解决有序性问题

什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态**。任务从保存到再加载的过程就是一次上下文切换**。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

守护线程和用户线程有什么区别呢?

守护线程和用户线程
  • 用户 (User) 线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程

  • 守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作

main 函数所在的线程就是一个用户线程啊,main 函数启动的同时在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。

比较明显的区别之一是用户线程结束,JVM 退出,不管这个时候有没有守护线程运行。而守护线程不会影响 JVM 的退出

注意事项:

  • setDaemon(true)必须在start()方法前执行,否则会抛出 IllegalThreadStateException 异常
  • 在守护线程中产生的新线程也是守护线程
  • 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑
  • 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 (Daemon) 线程中的 finally 语句块可能无法被执行。

如何在 Windows 和 Linux 上查找哪个线程cpu利用率最高?

windows上面用任务管理器看,linux下可以用 top 这个工具看:

  • 找出cpu耗用厉害的进程pid, 终端执行top命令,然后按下shift+p 查找出cpu利用最厉害的pid号
  • 根据上面第一步拿到的pid号,top -H -p pid 。然后按下shift+p,查找出cpu利用率最厉害的线程号,比如top -H -p 1328
  • 将获取到的线程号转换成16进制,去百度转换一下就行
  • 使用jstack工具将进程信息打印输出,jstack pid号 > /tmp/t.dat,比如jstack 31365 > /tmp/t.dat
  • 编辑/tmp/t.dat文件,查找线程号对应的信息

什么是线程死锁

百度百科:死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 a,线程 B 持有资源 b,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态

在这里插入图片描述

代码:

public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

输出结果:

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到CPU执行权,然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

形成死锁的四个必要条件是什么

  • 互斥条件:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
  • 请求与保持条件一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程(进程)已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞

如何避免线程死锁

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

破坏互斥条件

  • 这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件

  • 一次性申请所有的资源。

破坏不剥夺条件

  • 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源

破坏循环等待条件

  • 按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
new Thread(() -> {
    synchronized (resource1) {
        System.out.println(Thread.currentThread() + "get resource1");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread() + "waiting get resource2");
        synchronized (resource2) {
            System.out.println(Thread.currentThread() + "get resource2");
        }
    }
}, "线程 2").start();

输出结果:

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

我们还可以利用银行家算法来避免死锁问题【在动态分配资源的过程中,银行家算法防止系统进入不安全状态,从而避免死锁】

银行家算法:

当进程首次申请资源时,要测试该进程对资源的最大需求量,如果系统现存的资源可以满足它的最大需求量则按当前的申请量分配资源,否则就推迟分配。

当进程在执行中继续申请资源时,先测试该进程已占用的资源数与本次申请资源数之和是否超过了该进程对资源的最大需求量。若超过则拒绝分配资源。若没超过则再测试系统现存的资源能否满足该进程尚需的最大资源量,若满足则按当前的申请量分配资源,否则也要推迟分配

创建线程的方式(重要!!!)

方式1:继承Thread类实现多线程
  • run()为线程类的核心方法,相当于主线程的main方法,是每个线程的入口
  • 一个线程调用 两次start()方法将会抛出线程状态异常,也就是的start()只可以被调用一次
  • run()方法是由jvm创建完本地操作系统级线程后回调的方法,不可以手动调用(否则就是普通方法)
public class MyThread1 extends Thread{
    @Override
    public void run() {
        System.out.println("MyThread1 run()");
    }
}
方式2:实现Runnable接口(推荐!!!)
  • 覆写Runnable接口实现多线程可以避免单继承局限
  • 当子类实现Runnable接口,此时子类和Thread的代理模式(子类负责真是业务的操作,thread负责资源调度与线程创建辅助真实业务)
public class MyThread2 implements Runnable{
    @Override
    public void run() {
        System.out.println("MyThread2 run()");
    }
}

public class Test {
    public static void main(String[] args) {
        MyThread2 myThread2 = new MyThread2();
        Thread t1 = new Thread(myThread2,"t1");
        t1.start();
    }
}
Thread和Runnable方式的区别
  • Thread是类,而Runnable是接口

  • 实现Runnable接口避免多继承局限

  • 实现Runnable()可以更好的体现共享的概念

  • 使得线程的创建和线程实体进行解耦

方式3:覆写Callable接口实现多线程(JDK1.5)
  • 核心方法叫call()方法,有返回值
public class MyThread3 implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "feng";
    }
}

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyThread3 thread = new MyThread3();
        FutureTask<String> futureTask = new FutureTask<String>(thread);
        Thread t2 = new Thread(futureTask);
        t2.start();
        //返回值即为call()方法的返回值
        String result = futureTask.get();
        System.out.println(result);
    }
}
Runnable和Callable的相同点和区别

相同点:

  • 都是接口
  • 都可以编写多线程程序
  • 都采用Thread.start()启动线程

不同点:

  • Runnable执行的是**run()方法,Callable执行的是call()**方法

  • 实现Runnable接口的任务线程无返回值;实现Callable接口的任务线程能返回执行结果

  • Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息

Callable接口需要注意的点:
  • Callable接口支持返回执行结果,需要调用**FutureTask.get()**方法实现,此方法会阻塞主线程直到获取结果;当不调用此方法时,主线程不会阻塞!
  • 如果线程出现异常,Future.get()会抛出InterruptedException或者ExecutionException;如果线程已经取消,会抛出CancellationException
方式4:通过线程池启动多线程
  • 通过Executor 的工具类可以创建三种类型的普通线程池
FixThreadPool(int n):固定大小的线程池

使用于为了满足资源管理需求而需要限制当前线程数量的场合。使用于负载比较重的服务器。

public class Test {
	public static void main(String[] args) {
        //创建固定大小的线程池
		ExecutorService ex = Executors.newFixedThreadPool(5);
		for(int i = 0;i < 5;i++) {
			ex.submit(new Runnable() {
				@Override
				public void run() {
					for(int j = 0;j < 10;j++) {
						System.out.println(Thread.currentThread().getName() + j);
					}
				}
			});
		}
		ex.shutdown();
	}
}
SingleThreadPoolExecutor : 单线程池

需要保证顺序执行各个任务的场景

public class Test {
	public static void main(String[] args) {
        //创建一个单线程池
		ExecutorService ex = Executors.newSingleThreadExecutor();
		for(int i = 0;i < 5;i++) {
			ex.submit(new Runnable() {
				@Override
				public void run() {
					for(int j = 0;j < 10;j++) {
						System.out.println(Thread.currentThread().getName() + j);
					}
					
				}
			});
		}
		ex.shutdown();
	}	
}
CashedThreadPool():缓存线程池

当提交任务速度高于线程池中任务处理速度时,缓存线程池会不断的创建线程。适用于提交短期的异步小程序,以及负载较轻的服务器

public class Test {
	public static void main(String[] args) {
        //创建缓存线程池
		ExecutorService ex = Executors.newCachedThreadPool();
		for(int i = 0;i < 5;i++) {
			ex.submit(new Runnable() {	
				@Override
				public void run() {
					for(int j = 0;j < 10;j++) {
						System.out.println(Thread.currentThread().getName() + j);
					}
				}
			});
		}
		ex.shutdown();
	}	
}

什么是 Callable 和 Future?

Callable 接口类似于 Runnable,从名字就可以看出来了,但是 Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。

Future 接口表示异步任务,是一个可能还没有完成的异步任务的结果。所以说 Callable用于产生结果,Future 用于获取结果

什么是 FutureTask

FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。

线程的状态(五种)和基本操作

新建(new):新创建了一个线程对象。

可运行(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。

运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被 CPU 调用以进入到运行状态。

阻塞的情况分三种:

  • (一). 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
  • (二). 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
  • (三). 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

在这里插入图片描述

Java中用到的线程调度算法是什么?

计算机通常只有一个 CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU 的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得 CPU 的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待 CPU,JAVA 虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配 CPU 的使用权。

有两种调度模型:分时调度模型和抢占式调度模型

分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。

Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。

线程的调度策略

线程调度器选择优先级最高的线程运行,但是,如果发生以下情况,就会终止线程的运行:

(1)线程体中调用了 yield 方法让出了对 cpu 的占用权利

(2)线程体中调用了 sleep 方法使线程进入睡眠状态

(3)线程由于 IO 操作受到阻塞

(4)另外一个更高优先级线程出现

(5)在支持时间片的系统中,该线程的时间片用完

什么是线程调度器(Thread Scheduler)和时间分片(Time Slicing )?

线程调度器是一个操作系统服务,它负责为 Runnable 状态的线程分配 CPU 时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。

时间分片是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配 CPU 时间可以基于线程优先级或者线程等待的时间。

线程调度并不受到 Java 虚拟机控制,所以由应用程序来控制它是更好的选择(也就是说不要让你的程序依赖于线程的优先级)。

请说出与线程同步以及线程调度相关的方法:

(1) wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;

(2)sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常;

(3)notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;

(4)notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;

sleep() 和 wait() 有什么区别?

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒

wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。下面是一段标准的使用 wait 和 notify 方法的代码:

synchronized (monitor) {
    //  判断条件谓词是否得到满足
    while(!locked) {
        //  等待唤醒
        monitor.wait();
    }
    //  处理其他的业务逻辑
}

Thread 类中的 yield 方法有什么作用?

使当前线程从执行状态(运行状态)变为可执行态(就绪状态)

当前线程到了就绪状态,那么接下来哪个线程会从就绪状态变成执行状态呢?可能是当前线程,也可能是其他线程,看系统的分配了。

线程的 sleep()方法和 yield()方法有什么区别?

(1) sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;

(2) 线程执行 sleep()方法后转入**阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)**状态;

(3)sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常

(4)sleep()方法比 yield()方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。

如何停止一个正在运行的线程?

在java中有以下3种方法可以终止正在运行的线程:

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。

  • 使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。

  • 使用interrupt方法中断线程,并监视该线程的状态进行停止处理

Java 中 interrupt,interrupted 和 isInterrupted 方法的区别?

interrupt:用于中断线程。调用该方法的线程的状态为将被置为”中断”状态,即将线程的中断状态置为true。如果目前该线程被一个sleep调用阻塞,那么会抛出interruptedException 异常。

注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常

interrupted:是静态方法,查看当前中断信号是true还是false并且清除中断信号,即将中断标志位置为false如果一个线程被中断了,第一次调用 interrupted 则返回 true,第二次和后面的就返回 false 了

isInterrupted:查看当前中断信号是true还是false

final,finally,finalize的区别

final:java中的关键字,修饰符

A) 如果一个类被声明为final,就意味着它不能再派生出新的子类,不能作为父类被继承。因此,一个类不能同时被声明为abstract抽象类的和final的类。

B) 如果将变量或者方法声明为final,可以保证它们在使用中不被改变.
  1)被声明为final的变量必须在声明时给定初值,而在以后的引用中只能读取,不可修改。
  2)被声明final的方法只能使用,不能重载。

finally:java的一种异常处理机制

finally是对Java异常处理模型的最佳补充。finally结构使代码总会执行,而不管无异常发生【如果try代码块存在return返回语句,则我们的finally在return之前执行】。使用finally可以维护对象的内部状态,并可以清理非内存资源。特别是在关闭数据库连接这方面,如果程序员把数据库连接的close()方法放到finally中,就会大大降低程序出错的几率。

finalize:Java中的一个方法名

Java技术使用finalize()方法在垃圾收集器将对象从内存中清除出去前,做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没被引用时对这个对象调用的。它是在Object类中定义的,因此所的类都继承了它。子类覆盖finalize()方法以整理系统资源或者执行其他清理工作。finalize()方法是在垃圾收集器删除对象之前对这个对象调用的。

线程池问题

什么是线程池?什么是Executor框架?

线程池就是线程的集合,线程池集中管理线程,以实现线程的重用,降低资源消耗,提高响应速度等。线程用于执行异步任务,单个的线程既是工作单元也是执行机制,从JDK1.5开始,为了把工作单元与执行机制分离开,Executor框架诞生了,他是一个用于统一创建与运行的接口。Executor框架实现的就是线程池的功能

java.util.concurrent.Executors提供了一个 java.util.concurrent.Executor接口的实现来用于创建线程池

多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力

线程池的优点:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池基本组成部分:

  • 1、线程池管理器(ThreadPool):用于创建并管理线程池,包括 创建线程池,销毁线程池,添加新任务;
  • 2、工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
  • 3、任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
  • 4、任务队列(taskQueue):用于存放没有处理的任务。提供一种缓冲机制。

Executor的组成部分:

(1)任务。也就是工作单元,包括被执行任务需要实现的接口:Runnable接口或者Callable接口;

(2)任务的执行。也就是把任务分派给多个线程的执行机制,包括Executor接口及继承自Executor接口的ExecutorService接口。

(3)异步计算的结果。包括Future接口及实现了Future接口的FutureTask类。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LqrkgDEh-1632446503158)(.\imgs\Executor.png)]

使用线程池工具类(Executors)创建线程的几种方式:

(1)newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。

(2)newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool方法来创建线程池,这样能获得更好的性能。

(3)newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。

(4)newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

为什么不建议使用Executors静态工厂创建线程池?

线程池不建议我们使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让创建线程的工作者更加明确线程池的运行规则,规避资源耗尽的风险

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

线程池常用参数

参数名称详细信息
corePoolSize核心线程数量,会一直存在,除非allowCoreThreadTimeOut设置为true
maximumPoolSize线程池允许的最大线程数量
keepAliveTime线程数量超过corePoolSize,空闲线程的最大超时时间
unit超时时间的单位
workQueue工作队列,保存未执行的Runnable任务
threadFactory创建线程的工厂类
handler当线程已满,工作队列也满了的时候,会被调用。被用来实现各种拒绝策略

在 Java 中 Executor 和 Executors 的区别?

  • Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。

  • Executor 接口对象能执行我们的线程任务。

  • ExecutorService 接口继承了 Executor 接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。

  • 使用 ThreadPoolExecutor 可以创建自定义线程池。

  • Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用 get()方法获取计算的结果。

线程池中 submit() 和 execute() 方法有什么区别?

  • 接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。

  • 返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有

  • 异常处理:submit()方便Exception处理

ThreadPoolExecutor饱和策略

ThreadPoolExecutor 饱和策略定义:

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

线程池实现原理

在这里插入图片描述

自定义线程池:

步骤:

  • 步骤1:自定义任务队列
  • 步骤2:自定义线程池
  • 步骤3:自定义拒绝策略接口
  • 步骤4:进行代码测试

任务队列类

package com.feng.ThreadPool;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Created by FengBin on 2021/8/3 9:37
 */
@Slf4j
public class BlockingQueue<T> {
    //任务队列
    private Deque<T> queue = new ArrayDeque<>();
    //可重入锁
    ReentrantLock lock = new ReentrantLock();
    //生产者条件变量【等待队列】
    private Condition fullWaitSet = lock.newCondition();
    //消费者条件变量【等待队列】
    private Condition emptyWaitSet = lock.newCondition();
    //阻塞队列的容量
    private int capacity;

    //构造方法
    public BlockingQueue(int capacity) {
        this.capacity = capacity;
    }

    //阻塞添加任务
    public void put(T task) {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                //此时队列已满,我们进行等待
                try {
                    log.debug("等待加入任务队列 {} ...", task);
                    fullWaitSet.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            log.debug("加入任务队列 {}", task);
            //队列未满,我们添加任务到任务队列中
            queue.addLast(task);
            //同时唤醒消费者等待的条件变量
            emptyWaitSet.signal();
        }finally {
            lock.unlock();
        }
    }

    //阻塞获取任务
    public T take() {
        lock.lock();
        try{
            while (queue.isEmpty()) {
                //此时队列为空
                try {

                    emptyWaitSet.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //队列不为空,我们获取任务
            T t = queue.removeFirst();
            fullWaitSet.signal();
            return t;
        }finally {
            lock.unlock();
        }
    }

    //带有超时时间的阻塞添加
    public boolean offer(T task, long timeout, TimeUnit timeUnit) {
        lock.lock();
        try {
            long nacos = timeUnit.toNanos(timeout);  //将超时时间转化
            while (queue.size() == capacity) {
                try {
                    if (nacos <= 0) {
                        //此时已经超过了超时时间
                        return false;
                    }
                    log.debug("等待加入任务队列 {} ...", task);
                    nacos = fullWaitSet.awaitNanos(nacos);  //这里注意我们需要将等待的结果【剩余等待时间赋值为nacos】,不然又会重新计时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            log.debug("加入任务队列 {}", task);
            queue.addLast(task);
            emptyWaitSet.signal();
            return true;
        }finally {
            lock.unlock();
        }
    }

    //带有超时时间的阻塞获取
    public T poll(long timeout, TimeUnit timeUnit) {
        lock.lock();
        try {
            long nacos = timeUnit.toNanos(timeout);
            while (queue.isEmpty()) {
                try {
                    if (nacos <= 0) {
                        //此时已经超时了...
                        return null;
                    }
                    nacos = emptyWaitSet.awaitNanos(nacos);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            T t = queue.removeFirst();
            fullWaitSet.signal();
            return t;
        }finally {
            lock.unlock();
        }
    }

    public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
        lock.lock();
        try {
            //判断队列是否满
            if (queue.size() == capacity) {
                rejectPolicy.reject(this,task);
            }else {
                log.debug("加入任务队列 {}", task);
                queue.addLast(task);
                emptyWaitSet.signal();
            }
        }finally {
            lock.unlock();
        }
    }

    //工作队列的长度
    public int size() {
        lock.lock();
        try {
            return queue.size();
        } finally {
            lock.unlock();
        }
    }
}

线程池类:

package com.feng.ThreadPool;
import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.concurrent.TimeUnit;
/**
 * Created by FengBin on 2021/8/3 9:18
 */
@Slf4j
public class ThreadPool {
    //阻塞队列
    private BlockingQueue<Runnable> workQueue;
    //线程的集合
    private HashSet<Worker> workers = new HashSet<>();
    //核心线程数
    private int corePoolSize;
    //获取任务的超时时间
    private long timeout;
    //超时单位
    private TimeUnit unit;
    //拒绝策略
    private RejectPolicy<Runnable> rejectPolicy;

    public ThreadPool(int workQueueCapacity, int corePoolSize, long timeout, TimeUnit unit, RejectPolicy<Runnable> rejectPolicy) {
        this.workQueue = new BlockingQueue<>(workQueueCapacity);
        this.corePoolSize = corePoolSize;
        this.timeout = timeout;
        this.unit = unit;
        this.rejectPolicy = rejectPolicy;
    }

    //执行任务的方法
    public void execute(Runnable task) {
        synchronized (workers) {
            if (workers.size() < corePoolSize) {
                Worker worker = new Worker(task);
                log.debug("新增 worker{}, {}", worker, task);
                workers.add(worker);
                worker.start();
            }else {
                //此时任务数超过了核心线程数,我们使用带拒绝策略的添加方法
                workQueue.tryPut(rejectPolicy,task);
            }
        }
    }

    //内部类
    class Worker extends Thread {
        private Runnable task;

        public Worker(Runnable task) {
            this.task = task;
        }

        @Override
        public void run() {
            //当task不为空时,我们执行任务
            //当task执行完毕,我们接着从任务队列中获取任务并进行执行
            while (task != null || (task = workQueue.poll(timeout,unit)) != null) {
                try {
                    log.debug("正在执行...{}", task);
                    task.run();
                }catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    //任务执行完毕,将任务置为null
                    log.debug("worker 被移除{}", this);
                    task = null;
                }
            }

            synchronized (workers) {
                //此时所有任务执行结束,我们从线程集合中移除该worker
                workers.remove(this);
            }
        }
    }
}

拒绝策略接口:

@FunctionalInterface
public interface RejectPolicy<T> {
    //拒绝策略方法
    void reject(BlockingQueue<T> workQueue, T task);
}

测试自定义线程池的方法:

package com.feng.ThreadPool;
import java.util.concurrent.TimeUnit;
public class TestPool {
    public static void main(String[] args) {
       ThreadPool threadPool = new ThreadPool(1,1,1000, TimeUnit.MILLISECONDS,(queue,task)->{
//           这里编写我们的拒绝策略
//           1.死等策略
             queue.put(task);
//           2.带超时的等待
//           queue.offer(task,1500,TimeUnit.MILLISECONDS);
//           3.让调用者放弃任务执行
//           System.out.println("放弃" + task + "任务");
//           4.让调用者抛出异常
//           throw new RuntimeException("任务执行失败" + task);
//           5.让调用者自己执行任务
//           task.run();
        });


        for (int i = 0; i < 4; i++) {
            int j = i;
            threadPool.execute(()-> {
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

线程池的常见方法解析

// 1.执行任务
void execute(Runnable command);

// 2.提交任务 task,用返回值 Future 获得任务执行结果,Future的原理就是利用我们之前讲到的保护性暂停模式来接受返回结果的,主线程可以执行 FutureTask.get()方法来等待任务执行完成
<T> Future<T> submit(Callable<T> task);

// 3.提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
 throws InterruptedException;

// 4.提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
 long timeout, TimeUnit unit)
 throws InterruptedException;

// 5.提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
 throws InterruptedException, ExecutionException;

// 6.提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
 long timeout, TimeUnit unit)
 throws InterruptedException, ExecutionException, TimeoutException;

// 7.线程池状态变为SHUTDOWN,不会接收新任务,但已提交任务会执行完,包括等待队列里面的,此方法不会阻塞调用线程的执行
void shutdown()
    
// 8.线程池状态变为 STOP,不会接收新任务,会将队列中的任务返回,并用interrupt的方式中断正在执行的任务
void shutdownNow()

在JDK1.7的时候引入了另外一种线程池的实现方式:Fork/Join

Fork/Join

1) 概念
  1. Fork/Join 是 JDK 1.7 加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分cpu 密集型运算
  2. 所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计算,如归并排序、斐波那契数列、都可以用分治思想进行求解
  3. Fork/Join 在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运算效率
  4. Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
2) 使用

提交给 Fork/Join 线程池的任务需要继承 RecursiveTask(有返回值)或 RecursiveAction(没有返回值),例如下
面定义了一个对 1~n 之间的整数求和的任务

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

@Slf4j
public class TestFork{

    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool(4);
        System.out.println(pool.invoke(new MyTask(5)));

        // new MyTask(5)  5+ new MyTask(4)  4 + new MyTask(3)  3 + new MyTask(2)  2 + new MyTask(1)
    }
}

// 1~n 之间整数的和
@Slf4j(topic = "c.MyTask")
class MyTask extends RecursiveTask<Integer> {

    private int n;

    public MyTask(int n) {
        this.n = n;
    }

    @Override
    public String toString() {
        return "{" + n + '}';
    }

    @Override
    protected Integer compute() {
        // 如果 n 已经为 1,可以求得结果了
        if (n == 1) {
            log.debug("join() {}", n);
            return n;
        }

        // 将任务进行拆分(fork)
        MyTask t1 = new MyTask(n - 1);
        t1.fork();
        log.debug("fork() {} + {}", n, t1);

        // 合并(join)结果
        int result = n + t1.join();
        log.debug("join() {} + {} = {}", n, t1, result);
        return result;
    }
}

输出结果:

13:23:44.029 [ForkJoinPool-1-worker-2] DEBUG c.MyTask - fork() 4 + {3}
13:23:44.029 [ForkJoinPool-1-worker-1] DEBUG c.MyTask - fork() 5 + {4}
13:23:44.029 [ForkJoinPool-1-worker-3] DEBUG c.MyTask - fork() 3 + {2}
13:23:44.029 [ForkJoinPool-1-worker-0] DEBUG c.MyTask - fork() 2 + {1}
13:23:44.033 [ForkJoinPool-1-worker-3] DEBUG c.MyTask - join() 1
13:23:44.033 [ForkJoinPool-1-worker-0] DEBUG c.MyTask - join() 2 + {1} = 3
13:23:44.033 [ForkJoinPool-1-worker-3] DEBUG c.MyTask - join() 3 + {2} = 6
13:23:44.033 [ForkJoinPool-1-worker-2] DEBUG c.MyTask - join() 4 + {3} = 10
13:23:44.033 [ForkJoinPool-1-worker-1] DEBUG c.MyTask - join() 5 + {4} = 15
15

改进:

@Slf4j(topic = "c.AddTask")
class AddTask extends RecursiveTask<Integer> {

    int begin;
    int end;

    public AddTask(int begin, int end) {
        this.begin = begin;
        this.end = end;
    }

    @Override
    public String toString() {
        return "{" + begin + "," + end + '}';
    }

    @Override
    protected Integer compute() {
        if (begin == end) {
            log.debug("join() {}", begin);
            return begin;
        }
        if (end - begin == 1) {
            log.debug("join() {} + {} = {}", begin, end, end + begin);
            return end + begin;
        }
        int mid = (end + begin) / 2;

        AddTask t1 = new AddTask(begin, mid);
        t1.fork();
        AddTask t2 = new AddTask(mid + 1, end);
        t2.fork();
        log.debug("fork() {} + {} = ?", t1, t2);

        int result = t1.join() + t2.join();
        log.debug("join() {} + {} = {}", t1, t2, result);
        return result;
    }
}

算法逻辑图

在这里插入图片描述

Fork/Join框架与传统线程池的区别

采用 工作窃取 模式(work stealingstealing)

当执行新的任务时它可以将其拆分分成更小的任务执行,并将小任务加到线程队列中,然后再从一个随机线程的队列中偷一个并把它放在自己的队列中。
相对于一般的线程池实现,fork/join 框架的优势体现在对其中包含的任务的处理方式上。在一般的线程池中,如果一个线程正在执行的任务由于某些原因无法继续运行,那么该线程会处于等待状态,而在fork/join 框架实现中,如果某个子问题由于等待另外一个子问题的完成而无法继续运行, 那么**处理该子问题的线程会主动寻找其他尚未运行的子问题来执行, 这种方式减少了线程的等待时间 ,提高了性能。*

Java内存模型问题

java内存模型的起源

java虚拟机规范曾经试图定义一种"java内存模型"(即java memory model)来屏蔽各种硬件之间和操作系统的内存访问差异,以实现java程序在各个平台上都达到一致性的内存访问效果.

我们可以说JMM(java memory model)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

java内存模型的目的

java内存模型的主要目的是:定义程序中各种变量【共享变量】的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的过程的底层细节.

这里的变量主要指的是线程共享的变量:例如实例字段,静态字段和构成数组对象的元素;不包括线程中私有的局部变量和方法参数等等;

注意:

  • 为了获得更好的执行效率,java内存模型并没有限制执行引擎使用处理器的特定寄存器或者缓存来和主内存进行交互,也没有**限制即时编译器(JIT)**是否要进行调整代码执行的顺序这类的优化措施.

java内存模型中的主内存和工作内存

首先看一下计算机cpu多核并发的缓存架构图如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ecJ9m9Jk-1632446503161)(.\imgs\2.png)]

由于主内存(RAM)的读写速度基本是保持不变的,随着CPU的性能的提升,如果一直和主内存打交道,则会限制CPU的性能,故我们采取在CPU和主内存中间加一级高速缓存,将我们频繁使用的数据放置在缓存中,让CPU和高速缓存进行数据交互,从而大大加快了计算机的运行速度。

java线程的内存模型跟CPU缓存模型类似,是基于CPU缓存模型来建立的,java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别,其中,java线程的内存模型图如下所示:

它定义了**主存(线程之间共享的内存)、工作内存(线程之间私有的内存)**抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受到线程上下文切换的影响

  • 可见性 - 保证指令不会受 cpu 缓存的影响

  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

在这里插入图片描述

其中,java内存模型规定了所有的变量都存储在主内存(Main Memory)中,此处的主内存可以与前面的物理硬件的主内存类化,但是物理上它只是虚拟机内存的一部分而已;

同时,每一个线程还有属于自己的工作内存(Working Memory),此处的工作内存与前面的处理器缓存类化,线程中的工作内存保存了被该线程使用的变量的主内存副本 ,线程对变量的所有操作(读取,赋值)都必须在工作内存中进行,而不能直接写主内存中的数据.不同的是线程之间也无法直接访问对方的工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,这样,线程,主内存,工作内存三者之间就形成了一种交互的关系.

JMM数据原子操作

java对于一个变量如何从内存拷贝到工作内存,如何从工作内存同步到主内存这一类的实现细节,java内存模型定义8种操作:

操作解释
lock(锁定)作用于主内存的变量,它把一个变量标识为一条线程独占的状态
unlock(解锁)作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取)作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入)作用于工作内存的变量,它把read操作从主内存中得到的变量值放到工作内存的变量副本中
use(使用)作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
assign(赋值)作用于工作内存的变量,它把一个执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
store(存储)作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
write(写入)作用于主内存的变量,它把store操作从工作内存中得到的变量的值放到主内存的变量中

程序代码演示:

public class VolatileTest {
    private static boolean initFlag = false;
    public static void main(String[] args) throws Exception {
        //定义线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("waiting data ...");
                while (!initFlag){
                }
                System.out.println("===================success");
            }
        }).start();

        //让线程1先执行
        Thread.sleep(2000);

        //定义线程2
        new Thread(new Runnable() {
            @Override
            public void run() {
                prepareData();
            }
        }).start();
    }
    public static void prepareData() {
        System.out.println("prepareData ....");
        initFlag = true;
        System.out.println("prepareData ending ...");
    }
}

输出结果:

waiting data ...
prepareData ....
prepareData ending ...
处于死循环中...

java内存模型对于上面的代码的Java内存模型流程图如下所示:

在这里插入图片描述

出现死循环的原因在于:

线程1和线程2分别将主内存中的initFlag变量读取到自己的工作内存中,当线程2将initFlag变量的值改为true以后,此时虽然将其写入了主内存中,但是线程1中的initFlag还是之前的读取的false,并没有使用主内存中的值。

我这里分别使用synchronized关键字 + wait/notifyvolatile关键字的方式来解决:

synchronized关键字 + wait/notify:

public class testVolatile {
    private static boolean initFlag = false;
    public static void main(String[] args) throws Exception {
        //创建一个锁对象
        Object lock = new Object();
        //定义线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {
                    System.out.println("waiting data ...");
                    while (!initFlag){
                        try {
                            //让当前线程等待,让出时间片
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("===================success");
                }
            }
        }).start();

        //让线程1先执行
        Thread.sleep(2000);

        //定义线程2
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {
                    prepareData();
                    //随机唤醒一个当前锁的其他等待线程,只有线程1一个
                    lock.notify();
                }
            }
        }).start();
    }
    public static void prepareData() {
        System.out.println("prepareData ....");
        initFlag = true;
        System.out.println("prepareData ending ...");
    }
}

volatile关键字:

public class VolatileTest {
    //使用volatile关键字修饰initFlag关键字保持其在其他线程中的可见性
    private static volatile boolean initFlag = false;
    public static void main(String[] args) throws Exception {
        //定义线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("waiting data ...");
                while (!initFlag){
                }
                System.out.println("===================success");
            }
        }).start();

        //让线程1先执行
        Thread.sleep(2000);

        //定义线程2
        new Thread(new Runnable() {
            @Override
            public void run() {
                prepareData();
            }
        }).start();
    }
    public static void prepareData() {
        System.out.println("prepareData ....");
        initFlag = true;
        System.out.println("prepareData ending ...");
    }
}

JMM的缓存不一致性的发展:

为了解决高并发线程访问数据的一致性,使用了两种方式进行解决:

方式1:总线加锁(性能太低)

即cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu没法去读或者写这个数据,直到这个cpu使用完数据释放锁之后其他cpu才能读取该数据

方式2:MESI缓存一致性协议(性能高)

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步到主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效.

Volatile缓存可见性实现原理

volatile底层是用c语言实现当我们ctrl+鼠标左键选择volatile时,无法查看底层实现

我们需要下载进行反编译的工具包,解压到jdk/jre/bin目录下

JVM设置进行反编译:

-server -Xcomp -XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly -XX:CompileCommand=compileonly,*testVolatile.prepareData

通过将java代码反编译成汇编语言,可以发现,volatile底层实现主要是通过lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定),并回写到主内存中;

IA-32架构软件开发者手册对底层lock指令的解释:

  1. 会将当前处理器缓存行的数据立即写回系统主内存

  2. 这个写回主内存的操作会引起其他cpu里缓存了该内存地址的数据无效(MES)

我们在上面的程序中volatile使用即是利用上面的原理机制—lock,代码的volatile实现原理图如下所示:

并发编程三大特性 可见性,原子性,有序性

Volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制

我们首先看一下下面这段代码:

public class VolatileTest2 {
    public static volatile int num = 0;
    public static void increase(){
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        //定义一个线程数组
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 100; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        
        for (Thread t : threads) {
            //阻塞调用此方法的线程及进入WAITING状态,直到线程t执行结束,该线程才会继续
            //这里是main线程调用的,故等到上面的10个线程执行完毕,才会继续执行主线程main
            t.join();
        }
        
        //最终输出num的数值
        System.out.println("num =" + num);
    }
}

num的值小于等于1000的原因:

假如十个线程中同时有两个线程获取到num的值(刚开始为0),同时进行num++操作,将num的值由0增加至1,然后此时,将新的num值写回到主内存的时候,有一定的先后次序,如果此时线程1速度比较快,先写回主内存,当通过总线时,线程2嗅探到num值的变化,则此时线程2选择将其num值失效,然后重新获取,但是此时的++操作已经丢失.最后导致num值不能为1000;

Volatile禁止指令重排

在这里插入图片描述

Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象

首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的顺序
  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

在这里插入图片描述

也就是过在Volatile的写和读的时候,加入屏障,防止出现指令重排的

线程安全获得保证

工作内存与主内存同步延迟现象导致的可见性问题

  • 可通过synchronized或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其它线程可见

对于指令重排导致的可见性问题和有序性问题

  • 可以使用volatile关键字解决,因为volatile关键字的另一个作用就是禁止重排序优化

为什么代码会重排序?

在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:

  • 在单线程环境下不能改变程序运行的结果;

  • 存在数据依赖关系的不允许重排序

需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。

as-if-serial规则和happens-before规则的区别

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

CAS问题

概述:

CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术.

CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B

CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作

CAS是标准的乐观锁的实现,它是调用 **JNI(Java Native Interface)**的代码实现的,比如,在 Windows 系统 CAS 就是借助 C 语言来调用 CPU 底层指令实现的

CAS的缺点:

CAS虽然能够高效的解决原子操作(要么全部执行,要么不执行)问题,但是同样存在以下问题:

  • 循环时间长开销大(一般是配合无限循环进行使用的)

  • 只能保证一个变量的原子操作

  • 带来典型的ABA问题

ABA问题以及解决方式

CAS 的使用流程通常如下:

1)首先从地址 V 读取值 A;

2)根据 A 计算目标值 B;

3)通过 CAS 以原子的方式将地址 V 中的值从 A 修改为 B

此时如果如果在如果说我们在第一步读取的值是A,并且在第三步将值修改为B,就能保证它的值在第一步和第三步之间没有被其他线程修改过吗 ? 答案显然是不能保证,如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题.

如何解决?

Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

Java底层哪些地方使用到了CAS

Java的原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference,它们使用了Unsafe + CAS操作

Unsafe类

由于java不能直接地访问我们的操作系统底层,而是通过我们的本地方法(Native Method)进行访问,其中Unsafe类提供了硬件级别的原子操作,内部的方法均为native本地方法,通过其与操作系统进行交互。

Unsafe提供的功能

1. 通过Unsafe进行内存的分配和释放

类中提供的3个本地方法allocateMemory、reallocateMemory、freeMemory分别用于分配内存,扩充内存和释放内存,与C语言中的3个方法对应。

public native long allocateMemory(long l);
public native long reallocateMemory(long l, long l1);
public native void freeMemory(long l);
2. 可以定位对象某字段的内存位置,也可以修改对象的字段值,即使它是私有的

例如字段的定位:

JAVA中对象的字段的定位可能通过staticFieldOffset()方法实现,该方法返回给定field的内存地址偏移量,这个值对于给定的filed是唯一的且是固定不变的。
getIntVolatile()方法获取对象中offset偏移地址对应的整型field的值,支持volatile load语义。
getLong()方法获取对象中offset偏移地址对应的long型field的值
3. 挂起和恢复:
将一个线程进行挂起是通过park方法实现的,调用 park后,线程将一直阻塞直到超时或者中断等条件出现。unpark可以终止一个挂起的线程,使其恢复正常。
整个并发框架中对线程的挂起操作被封装在 LockSupport类中,LockSupport类中有各种版本pack方法,但最终都调用了Unsafe.park()方法。

LockSupport类源码:

public class LockSupport {
    public static void unpark(Thread thread) {
        if (thread != null)
            unsafe.unpark(thread);
    }

    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        unsafe.park(false, 0L);
        setBlocker(t, null);
    }

    public static void parkNanos(Object blocker, long nanos) {
        if (nanos > 0) {
            Thread t = Thread.currentThread();
            setBlocker(t, blocker);
            unsafe.park(false, nanos);
            setBlocker(t, null);
        }
    }

    public static void parkUntil(Object blocker, long deadline) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        unsafe.park(true, deadline);
        setBlocker(t, null);
    }

    public static void park() {
        unsafe.park(false, 0L);
    }

    public static void parkNanos(long nanos) {
        if (nanos > 0)
            unsafe.park(false, nanos);
    }

    public static void parkUntil(long deadline) {
        unsafe.park(true, deadline);
    }
}
4. CAS操作——是通过compareAndSwapXXX方法实现的
/**
* 比较obj的offset处内存位置中的值和期望的值,如果相同则更新。此更新是不可中断的。
* 
* @param obj 需要更新的对象
* @param offset obj中整型field的偏移量
* @param expect 希望field中存在的值
* @param update 如果期望值expect与field的当前值相同,设置filed的值为这个新值
* @return 如果field的值被更改返回true
*/
public native boolean compareAndSwapInt(Object obj, long offset, int expect, int update);

以上相关概念的比较问题

synchronized、volatile、CAS 比较

(1)synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞。

(2)volatile 提供多线程共享变量可见性和禁止指令重排序优化。

(3)CAS 是基于冲突检测的乐观锁(非阻塞)

synchronized 和 Lock 有什么区别?

  • 首先synchronized是Java内置关键字,在JVM层面,Lock是一个Java的接口
  • synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己手动加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

synchronized 和 ReentrantLock 区别是什么?

synchronized 是和 if、else、for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量

synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大,但是在 Java 6 中对 synchronized 进行了非常多的改进。

相同点:两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

主要区别如下:

  • ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
  • ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
  • ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
  • 二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word
  • ReentrantLock可以实现公平锁,synchronized为非公平锁
  • ReentrantLock底层实现了Lock接口,实现锁的功能

synchronized 和 volatile 的区别是什么?

synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。

volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序

区别

  • volatile 是变量修饰符;synchronized 可以修饰类、方法、变量

  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。

  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。volatile关键字只能用于变量,synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些

2. Lock体系问题

利用Lock接口和AbstractQueuedSynchronizer实现一个自定义锁MyLock

package com.feng.AQS;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
//自定义锁(不可重入锁)
public class MyLock implements Lock {

    //内部定义一个同步器类
    class MySync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0,1)) {
                //表示加上了锁
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            setExclusiveOwnerThread(null);
            setState(0); //volatile修饰,禁止指令重排
            return true;
        }

        //是否持有独占锁
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        //返回条件变量
        public Condition newCondition() {
            return new ConditionObject();
        }
    }

    //同步器对象属性
    private MySync sync = new MySync();


    //加锁方法(不成功,去等待队列)
    @Override
    public void lock() {
        sync.acquire(1);
    }

    //可打断的加锁的方法
    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    //尝试加锁(一次)
    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //尝试加锁,带超时
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }

    //解锁
    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

Java Concurrency API 中的 Lock 接口(Lock interface)是什么?对比同步它有什么优势?

Lock 接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。

它的优势有:

(1)可以使锁更公平

(2)可以使线程在等待锁的时候响应中断

(3)可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间

(4)可以在不同的范围,以不同的顺序获取和释放锁

整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。

乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。

乐观锁:

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

乐观锁的实现方式:

1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。

2、java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。 CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作。【前面基本概念已经讲解了CAS机制】

AQS(Abstract Queued Synchronizer)详解与源码分析

AQS介绍

AQS的全称为(Abstract Queued Synchronizer),这个类在java.util.concurrent.locks包下面。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LwYJJHLg-1632446503166)(.\imgs\AQS-1.png)]

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。其使用到了模板方法设计模式

AQS原理

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-opk71wZd-1632446503168)(.\imgs\AQS-2.png)]

AQS使用一个int成员变量来表示同步状态(state),通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

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

状态信息通过protected类型的getState,setState,compareAndSetState进行操作:

//返回同步状态的当前值
protected final int getState() {  
        return state;
}
 // 设置同步状态的值
protected final void setState(int newState) { 
        state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS 对资源的共享方式

AQS定义两种资源共享方式:

Exclusive(独占式):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

Share(共享式):多个线程可同时执行,如Semaphore/CountDownLatch

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

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

AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  • 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
  • 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

ReentrantLock(重入锁)实现原理与公平锁非公平锁区别

什么是可重入锁(ReentrantLock)?

ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。

java关键字synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入。与此同时,ReentrantLock还支持公平锁和非公平锁两种方式,默认是非公平方式(性能高)

读写锁ReentrantReadWriteLock

ReadWriteLock 是什么

首先明确一下,不是说 ReentrantLock 不好,只是 ReentrantLock 某些时候有局限。如果使用 ReentrantLock,可能本身是为了防止线程 A 在写数据、线程 B 在读数据造成的数据不一致,但这样,如果线程 C 在读数据、线程 D 也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。因为这个,才诞生了读写锁 ReadWriteLock

ReadWriteLock 是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock 是 ReadWriteLock 接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能

而读写锁有以下三个重要的特性

(1)公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。

(2)重进入读锁和写锁都支持线程重进入

(3)锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。

3. 并发容器问题

ConcurrentHashMap详解

ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现。平时涉及高并发如果要用map结构,那第一时间想到的就是它。相对于hashmap来说,ConcurrentHashMap就是线程安全的map,其中利用了锁分段的思想提高了并发度。

ConcurrentHashMap如何保证线程安全?

JDK1.7版本:

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

在这里插入图片描述

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁

JDK1.8版本:

ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树

在这里插入图片描述

synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

什么是并发容器的实现?

同步容器可以简单地理解为通过 synchronized 来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。比如 Vector,Hashtable,以及 Collections.synchronizedSet,synchronizedList 等方法返回的容器。可以通过查看 Vector,Hashtable 等这些同步容器的实现代码,可以看到这些容器实现线程安全的方式就是将它们的状态封装起来,并在需要同步的方法上加上关键字 synchronized。

并发容器使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性,例如在 ConcurrentHashMap 中采用了一种粒度更细的加锁机制,可以称为分段锁,在这种锁机制下,允许任意数量的读线程并发地访问 map,并且执行读操作的线程和写操作的线程也可以并发的访问 map,同时允许一定数量的写操作线程并发地修改 map,所以它可以在并发环境下实现更高的吞吐量。

Java 中的同步集合与并发集合有什么区别?

同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。在 Java1.5 之前程序员们只有同步集合来用且在多线程并发的时候会导致争用,阻碍了系统的扩展性。Java5 介绍了并发集合像ConcurrentHashMap,不仅提供线程安全还用锁分离和内部分区等现代技术提高了可扩展性。

SynchronizedMap 和 ConcurrentHashMap 有什么区别?

SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map。

ConcurrentHashMap 使用分段锁来保证在多线程下的性能。

ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。

这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。

另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当iterator 被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。

CopyOnWriteArrayList详解

CopyOnWriteArrayList 是一个并发容器。有很多人称它是线程安全的,我认为这句话不严谨,缺少一个前提条件,那就是非复合场景下操作它是线程安全的。

CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出 ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。

CopyOnWriteArrayList 的使用场景

通过源码分析,我们看出它的优缺点比较明显,所以使用场景也就比较明显。就是合适读多写少的场景。

CopyOnWriteArrayList 的缺点

  • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc。
  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。
  • 由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

CopyOnWriteArrayList 的设计思想

  • 读写分离,读和写分开
  • 最终一致性
  • 使用另外开辟空间的思路,来解决并发冲突

ThreadLocal详解

ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个 ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享

原理:线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。

经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。

代码:

public class TestThreadLocal {
    //线程本地存储变量
    private static final ThreadLocal<Integer> THREAD_LOCAL_NUM  = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 0;
        }
    };
 
    public static void main(String[] args) {
        for (int i = 0; i <3; i++) {//启动三个线程
            Thread t = new Thread() {
                @Override
                public void run() {
                    add10ByThreadLocal();
                }
            };
            t.start();
        }
    }
    
    /**
     * 线程本地存储变量加 5
     */
    private static void add10ByThreadLocal() {
        for (int i = 0; i <5; i++) {
            Integer n = THREAD_LOCAL_NUM.get();
            n += 1;
            THREAD_LOCAL_NUM.set(n);
            System.out.println(Thread.currentThread().getName() + " : ThreadLocal num=" + n);
        }
    }
    
}

输出结果:启动了 3 个线程,每个线程最后都打印到 “ThreadLocal num=5”,而不是 num 一直在累加直到值等于 15

Thread-0 : ThreadLocal num=1
Thread-1 : ThreadLocal num=1
Thread-0 : ThreadLocal num=2
Thread-0 : ThreadLocal num=3
Thread-1 : ThreadLocal num=2
Thread-2 : ThreadLocal num=1
Thread-0 : ThreadLocal num=4
Thread-2 : ThreadLocal num=2
Thread-1 : ThreadLocal num=3
Thread-1 : ThreadLocal num=4
Thread-2 : ThreadLocal num=3
Thread-0 : ThreadLocal num=5
Thread-2 : ThreadLocal num=4
Thread-2 : ThreadLocal num=5
Thread-1 : ThreadLocal num=5

什么是线程局部变量?

线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java 提供 ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险

ThreadLocal内存泄漏分析与解决方案

ThreadLocal造成内存泄漏的原因?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

ThreadLocal内存泄漏解决方案?
  • 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

  • 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。

BlockingQueue详解

什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻塞队列来实现生产者-消费者模型?

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列

这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

JDK7 提供了 7 个阻塞队列。分别是:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。

  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。

  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。

  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • SynchronousQueue:一个不存储元素的阻塞队列。

  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

Java 5 之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好,wait,notify,notifyAll,sychronized 这些关键字。而在 java 5 之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。

BlockingQueue 接口是 Queue 的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性,当生产者线程试图向 BlockingQueue 放入元素时,如果队列已满,则线程被阻塞,当消费者线程试图从中取出一个元素时,如果队列为空,则该线程会被阻塞,正是因为它所具有这个特性,所以在程序中多个线程交替向 BlockingQueue 中放入元素,取出元素,它可以很好的控制线程之间的通信。

阻塞队列使用最经典的场景就是 socket 客户端数据的读取和解析,读取数据的线程不断将数据放入队列,然后解析线程不断从队列取数据解析

原子操作类

什么是原子操作?在 Java Concurrency API 中有哪些原子类(atomic classes)?

原子操作(atomic operation)意为”不可被中断的一个或一系列操作” 。

处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。 CAS 操作——Compare & Set,或是 Compare & Swap,现在几乎所有的 CPU 指令都支持 CAS 的原子操作。

原子操作是指一个不受其他操作影响的操作任务单元。原子操作是在多线程环境下避免数据不一致必须的手段。

int++并不是一个原子操作,所以当一个线程读取它的值并加 1 时,另外一个线程有可能会读到之前的值,这就会引发错误。

为了解决这个问题,必须保证增加操作是原子的,在 JDK1.5 之前我们可以使用同步技术来做到这一点。到 JDK1.5,java.util.concurrent.atomic 包提供了 int 和long 类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。

java.util.concurrent 这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择另一个线程进入,这只是一种逻辑上的理解。

原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference

原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

原子属性更新器:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater

解决 ABA 问题的原子类:AtomicMarkableReference(通过引入一个 boolean来反映中间有没有变过),AtomicStampedReference(通过引入一个 int 来累加来反映中间有没有变过)

说一下 atomic 的原理?

Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。

AtomicInteger 类的部分源码:

// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
	try {
		valueOffset = unsafe.objectFieldOffset
		(AtomicInteger.class.getDeclaredField("value"));
	} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

4. 并发工具问题

CountDownLatch与CyclicBarrier

JAVA并发包中有三个类用于同步一批线程的行为,分别是闭锁(Latch),信号灯(Semaphore)和栅栏(CyclicBarrier)。这里我们主要来介绍一下:

闭锁(Latch)

闭锁即是一种同步方法,可以延迟线程的进度直到线程到达某个终点状态

通俗的讲就是,一个闭锁相当于一扇大门,在大门打开之前所有线程都被阻断,一旦大门打开所有线程都将通过,但是一旦大门打开,所有线程都通过了,那么这个闭锁的状态就失效了,门的状态也就不能变了,只能是打开状态。也就是说闭锁的状态是一次性的,它确保在闭锁打开之前所有特定的活动都需要在闭锁打开之后才能完成。

其中一个具体的例子就是我们的计数器闭锁(CountDownLatch),它是JDK5+中闭锁的一个实现,允许一个或者多个线程等待某一个事件的发生。

CountDownLatch 有个正数的计数器,countDown(): 对计数器做减法操作;await(): 等待计数器 = 0。

所有await的线程都会阻塞,直到计数器为0或者等待线程中断或者超时。

我们使用代码来举一个例子:

//MyRunnable类
public class MyRunnable implements Runnable{

    private final CountDownLatch await;
    private final int num;

    public MyRunnable(CountDownLatch await, int num) {
        this.await = await;
        this.num = num;
    }

    @Override
    public void run() {
        System.out.println("线程" + num + "执行完毕...");
        await.countDown();   //当前事件执行完毕,计数减1
    }
}

//测试类
public class TestCountDown {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch await = new CountDownLatch(5);
        for (int i = 1; i < 6; i++) {
            new Thread(new MyRunnable(await,i)).start();
        }

        System.out.println("等待线程运行结束...");
        await.await();
        System.out.println("五个线程已经运行结束...");

    }
}

输出结果:

线程1执行完毕...
等待线程运行结束...
线程4执行完毕...
线程2执行完毕...
线程3执行完毕...
线程5执行完毕...
五个线程已经运行结束...

说明主线程await以后,必须等待这五个线程运行结束才会执行

在这里插入图片描述

我们再来举一个例子:

此时三个工人在为老板干活,这个老板有一个习惯,就是当三个工人把一天的活都干完了的时候,他就来检查所有工人所干的活。记住这个条件:三个工人先全部干完活,老板才检查。

Worker工人类:

public class Worker implements Runnable{
    private CountDownLatch downLatch;
    private String name;

    public Worker(CountDownLatch downLatch, String name) {
        this.downLatch = downLatch;
        this.name = name;
    }


    @Override
    public void run() {
        this.work();
        try {
            Thread.sleep(new Random().nextInt(1000));
            System.out.println(this.name + "活干完了...");
            this.downLatch.countDown();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    private void work() {
        System.out.println(this.name + "正在干活...");
    }
}

Boss包工头类:

public class Boss implements Runnable{

    private CountDownLatch downLatch;

    public Boss(CountDownLatch downLatch) {
        this.downLatch = downLatch;
    }

    @Override
    public void run() {
        System.out.println("包工头等待工人干完活...");
        try {
            this.downLatch.await();
            System.out.println("工人们已经干完活了,现在进行检查...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

TestLauch测试类:

public class TestLatch {
    public static void main(String[] args) {

        CountDownLatch latch = new CountDownLatch(5);
        ExecutorService executorService = Executors.newCachedThreadPool();
        Worker worker1 = new Worker(latch, "张三");
        Worker worker2 = new Worker(latch, "李四");
        Worker worker3 = new Worker(latch, "王五");
        Worker worker4 = new Worker(latch, "赵六");
        Worker worker5 = new Worker(latch, "冯七");

        Boss boss = new Boss(latch);
        executorService.execute(worker1);
        executorService.execute(worker2);
        executorService.execute(worker3);
        executorService.execute(worker4);
        executorService.execute(worker5);
        executorService.execute(boss);

        executorService.shutdown();
    }
}

输出结果:

张三正在干活...
赵六正在干活...
王五正在干活...
李四正在干活...
包工头等待工人干完活...
冯七正在干活...
李四活干完了...
张三活干完了...
王五活干完了...
赵六活干完了...
冯七活干完了...
工人们已经干完活了,现在进行检查...

栅栏(CyclicBarrier)

栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。 栅栏与闭锁的关键区别在于,所有的线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。

场景: 接着上面的例子,还是这三个工人,不过这一次,这三个工人自由了,老板不用检查他们任务了,他们三个合作建桥,有三个桩,每人打一个,同时打完之后才能一起搭桥(搭桥需要三人一起合作)。也就是说三个人都打完桩之后才能继续工作。

Worker类:

public class Worker implements Runnable{

    private CyclicBarrier cyclicBarrier;
    private String name;

    public Worker(CyclicBarrier cyclicBarrier,String name) {
        this.cyclicBarrier = cyclicBarrier;
        this.name = name;
    }

    @Override
    public void run() {
        try {
            this.work();
            Thread.sleep(new Random().nextInt(1000));
            this.waitOther();
            cyclicBarrier.await();
            this.continueWork();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (BrokenBarrierException e) {
            e.printStackTrace();
        }
    }


    private void work() {
        System.out.println(this.name + "正在干活...");
    }

    private void waitOther() {
        System.out.println(this.name + "当前这部分工作干完了,等等他们吧...");
    }
    private void continueWork() {
        System.out.println("大家都干完这部分活了," + this.name + "又得忙活了...");
    }

}

TestCycleBarrier测试类:

public class TestCycleBarrier {
    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier = new CyclicBarrier(5);

        ExecutorService es = Executors.newCachedThreadPool();

        es.execute(new Worker(cyclicBarrier,"张三"));
        es.execute(new Worker(cyclicBarrier,"李四"));
        es.execute(new Worker(cyclicBarrier,"王五"));
        es.execute(new Worker(cyclicBarrier,"赵六"));
        es.execute(new Worker(cyclicBarrier,"冯七"));

        es.shutdown();
    }
}

输出结果:

张三正在干活...
冯七正在干活...
赵六正在干活...
李四正在干活...
王五正在干活...
李四当前这部分工作干完了,等等他们吧...
赵六当前这部分工作干完了,等等他们吧...
张三当前这部分工作干完了,等等他们吧...
王五当前这部分工作干完了,等等他们吧...
冯七当前这部分工作干完了,等等他们吧...
大家都干完这部分活了,冯七又得忙活了...
大家都干完这部分活了,李四又得忙活了...
大家都干完这部分活了,王五又得忙活了...
大家都干完这部分活了,张三又得忙活了...
大家都干完这部分活了,赵六又得忙活了..

在这里插入图片描述

闭锁和栅栏的区别:

  • 闭锁用于所有线程等待一个外部事件的发生;栅栏则是所有线程相互等待,直到所有线程都到达某一点时才打开栅栏,然后线程可以继续执行。

Semaphore(信号灯)与Exchanger

Semaphore详解

Semaphore 就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个 int 型整数 n,表示某段代码最多只有 n 个线程可以访问,如果超出了 n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果 Semaphore 构造函数中传入的 int 型整数 n=1,相当于变成了一个 synchronized 了

Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源

线程间交换数据的工具Exchanger

Exchanger是一个用于线程间协作的工具类,用于两个线程间交换数据。它提供了一个交换的同步点,在这个同步点两个线程能够交换数据。交换数据是通过exchange方法来实现的,如果一个线程先执行exchange方法,那么它会同步等待另一个线程也执行exchange方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据

常用的并发工具类有哪些?

  • Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch(倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。【一次性的】
  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。【可以重复使用】

5. 操作系统相关面试题

用户态和内核态

一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核状态

进程在执行用户自己的代码时,则称其处于用户态

当正在执行用户程序而突然中断时,此时用户程序也可以象征性地处于进程的内核态。因为中断处理程序将使用当前进程的内核态。

用户态和内核态的转换方式

a.系统调用

这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。

b.异常

当CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此异常的内核相关程序中,也就到了内核态,比如缺页异常。

c.外围设备的中断

当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

进程和线程【上面已经讲解了】

进程间通信的方式

  • 管道,命名管道,消息队列,信号量,信号,共享内存,套接字

进程间通信,我们说每一个进程都拥有不同的的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信

在这里插入图片描述

管道(pipe):

管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。【是由内核管理的一个缓冲区,速度慢,容量有限】

命名管道 (named pipe):

命名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信

消息队列( message queue ):

消息队列是由消息组成的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

是用于两个进程之间的通讯,首先在一个进程中创建一个消息队列,然后再往消息队列中写数据,而另一个进程则从那个消息队列中取数据。需要注意的是,消息队列是用创建文件的方式建立的,如果一个进程向某个消息队列中写入了数据之后,另一个进程并没有取出数据,即使向消息队列中写数据的进程已经结束,保存在消息队列中的数据并没有消失,也就是说下次再从这个消息队列读数据的时候,就是上次的数据。

信号量( semophore ):

信号量是一个计数器,可以用来控制多个进程对共享资源的访问。

它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段

信号 ( signal ):

用于通知接收进程某个事件已经发生。

共享内存( shared memory ):

共享内存由一个进程创建,但多个进程都可以访问。【两个不同进程 A、B 共享内存的意思是:同一块物理内存被映射到进程 A、B 各自的进程地址空间。进程 A 可以即时看到进程 B 对共享内存中数据的更新,反之亦然】

套接字( socket ):

套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信

进程(或作业)的调度算法有哪些?

  • 先来先服务、短进程优先、优先权调度算法、高响应比优先调度算法、时间片轮转调度算法、多级队列调度算法

先来先服务(FCFS,First-Come-First-Served)

按照进程进入就绪队列的先后次序来选择进程。

短进程优先(SPF,Shortest Process Next)

从就绪队列中选出一个估计运行时间最短的进程,将处理机分配给它。

优先权调度算法(Priority)

按照进程的优先权大小来调度。

高响应比优先调度算法(HRRN,Highest Response Ratio Next)

按照高响应比((已等待时间+要求运行时间)/ 要求运行时间)优先的原则【等待时间长和运行时间短都会增加其优先值】,每次先计算就绪队列中每个进程的响应比,然后选择其值最大的进程投入运行。

时间片轮转调度算法(RR,Round-Robin)

当某个进程执行的时间片用完时,调度程序便停止该进程的执行,并将它送就绪队列的末尾,等待分配下一时间片再执行。然后把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。这样就可以保证就绪队列中的所有进程,在一给定的时间内,均能获得一时间片处理机执行时间。

多级队列调度算法

多队列调度是根据进程的性质和类型的不同,将就绪队列再分为若干个子队列,所有的进程按其性质排入相应的队列中,而不同的就绪队列采用不同的调度算法。

同步、异步、阻塞、非阻塞的区别

同步、异步【关注的是消息通信机制】

同步: 就是指调用者会主动等待调用的返回结果

异步: 就是指调用者不会主动等待调用结果,而是在调用发生后,被调用者通过状态、通知来通知调用者

阻塞、非阻塞 【关注的是程序在等待调用结果(消息,返回值)时的状态】

阻塞: 是指调用结果返回前,当前线程会被挂起,即阻塞。

非阻塞: 是指即使调用结果没返回,也不会阻塞当前线程

线程同步的方式

所谓线程同步,就是并发的线程在一些关键点上可能需要互相等待与互通信息这种相互制约的等待与互通信息称为进程(线程)同步

临界资源是指一次仅仅允许一个线程使用的资源

知道上面的概念以后,我们再来谈谈线程同步的方式:

  • 临界区用于单个进程中的线程的同步;

  • 互斥量,信号量以及事件作用于多个进程间的各个线程之间实现同步。

临界区

临界区对象。拥有临界区对象的线程可以访问被保护起来的资源或代码段,其他线程若想访问,则被挂起,直到拥有临界区对象的线程放弃临界区对象为止【只用于同一进程

互斥量【一个】

采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以可以保证公共资源不会同时被多个线程访问。【互斥对象和临界区对象非常相似,只是其允许在进程间使用,而临界区只限制于同一进程的各个线程之间使用】

信号量【多个】

它允许多个线程同一时刻访问同一资源,但是需要限制同一时刻访问此资源的最大线程数目

信号量(semaphore)的数据结构为一个值和一个指针,指针指向等待该信号量的下一个进程。信号量的值 S 与相应资源的使用情况关。当 S 大于 0 时,表示当前可用资源的数量;当 S 小于 0 时,其绝对值表示等待使用该资源的进程个数。注意,信号量的值仅能由 PV 操作来改变。

(1)执行一次 P 操作意味着请求分配一个单位资源,因此S的值减1;当 S < 0 时,表示已经没有可用资源,请求者必须等待别的进程释放该类资源,它才能运行下去。

(2)执行一个 V 操作意味着释放一个单位资源,因此 S 的值加 1;若 S < 0,表示有某些进程正在等待该资源,因此要唤醒一个等待状态的进程,使之运行下去。

事件(信号)

事件机制,则允许一个线程在处理完一个任务后,主动唤醒另外一个线程执行任务。【进程间通信中唯一的一个异步机制

什么是缓冲区溢出,有什么危害,原因是什么?

1、缓冲区溢出:

是指当计算机向缓冲区内填充数据时超过了缓冲区本身的容量,溢出的数据覆盖在合法数据上。

2、危害:

在当前网络与分布式系统安全中,被广泛利用的 50% 以上都是缓冲区溢出。缓冲区溢出中,最为危险的是堆栈溢出,因为入侵者可以利用堆栈溢出,在函数返回时改变返回程序的地址,让其跳转到任意地址,带来的危害一种是程序崩溃导致拒绝服务,另外一种就是跳转并且执行一段恶意代码,比如得到 shell,然后为所欲为。

通过往程序的缓冲区写超出其长度的内容,造成缓冲区的溢出,从而破坏程序的堆栈,使程序转而执行其它指令,以达到攻击的目的。

3、造成缓冲区溢出的主原因:

是程序中没有仔细检查用户输入的参数。

固定分区、动态分区、分段式存储管理和分页式存储管理的区别

内存分配分为连续分配非连续分配管理两种。

连续分配

其中连续分配又分为单一连续分配、固定分区连续分配、动态分区连续分配

1、单一连续分配:分为系统区和用户区,系统区供给操作系统使用,用户区供给用户使用,内存中永远只有一道程序。

2、固定分区分配:最简单的一种多道程序管理方式,它将用户内存空间划分为若干个固定大小的区域,每个分区只装入一道作业。

【方法一:分区大小相等;方法二:分区大小不等,划分为含有多个较小的分区,适量的中等分区及少量的大分区】

3、动态分区分配:又称为可变分区分配,是一种动态划分内存的方法。这种分区方法不预先将内存划分,而是在进程装入内存时,根据进程的大小动态的建立分区,并使分区的大小正好适合进程的需要。因此系统中分区的大小和数目是可变的。

非连续分配

非连续分配又分为分页式存储管理和分段式存储管理

1、分页式存储管理:分页存储管理是将一个进程的地址(逻辑地址空间)空间划分成若干个大小相等的区域,称为,相应地,将内存空间划分成与页相同大小(为了保证页内偏移一致)的若干个物理块,称为或页框(页架)。在为进程分配内存时,将进程中的若干页分别装入多个不相邻接的块中。【只需给出一个地址,所以是一维】

2、分段式存储管理:在分段存储管理方式中,作业的地址空间被划分为若干个,每个段是一组完整的逻辑信息,如有主程序段、子程序段、数据段及堆栈段等,每个段都有自己的名字,都是从零开始编址的一段连续的地址空间,各段长度是不等的。【因为每段的长度是不确定的,所以不能只给一个逻辑地址通过整数除法得到段号,求余得出段内偏移,所以一定要显式给出(段号,段内偏移),因此分段管理的地址空间是二维的】

逻辑地址、物理地址、虚拟内存

1、物理地址:它是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址从主存中存取,是内存单元真正的地址

2、逻辑地址:是指从应用程序角度看到的内存地址,又叫相对地址。编译后,每个目标模块都是从 0 号单元开始编址,称为该目标模块的相对地址或逻辑地址。不同进程可以有相同的逻辑地址,因为这些相同的逻辑地址可以映射到主存的不同位置。用户和程序员只需要知道逻辑地址。

3、虚拟内存:虚拟内存是一些系统页文件,存放在磁盘上,每个系统页文件大小为 4K,物理内存也被分页,每个页大小也为 4K,这样虚拟页文件和物理内存页就可以对应,实际上虚拟内存就是用于物理内存的临时存放的磁盘空间。页文件就是内存页,物理内存中每页叫物理页,磁盘上的页文件叫虚拟页,物理页+虚拟页就是系统所有使用的页文件的总和。

五种网络IO模型

  • 同步阻塞IO:当用户线程调用请求(如调用read(),write(),listen()等接口),内核就会等待数据的到来,数据到来时实行数据拷贝,然而在内核等待数据到来和实行数据拷贝这段时间用户线程就会被阻塞,直到数据到达线程是阻塞才解除
  • 同步非阻塞IO:默认创建的socket都是阻塞的,同步非阻塞是在同步阻塞的基础上,将socket设置为NONBLOCK,这个是用ioctl()系统调用设置。用户线程发起调用请求后可以立即返回,如果发起请求后并未读到数据,用户线程可以不断的发起请求,数据到达后,直到读到数据,继续执行。在IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但为了等到数据需要不断的轮询、重复请求,消耗大量的CPU的资源。因此一般很少用此模型。
  • IO多路复用:IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待问题,还有poll、epoll都是这种模型。在该模式下,用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到来时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续返回。虽然从流程上看select函数进行IO请求和同步阻塞模型没有太大区别,甚至还添加了监视socket的操作,效率更差,但使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。而在同步阻塞中,必须通过多线程的方式才能达到这个目的。
  • 信号驱动IO:调用sigaltion系统调用。当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。
  • 异步IO:在该模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。“真正”异步IO需要操作系统更强的支持。
  • 信号驱动和异步IO并不十分常用。我们一般使用IO多路复用模拟异步IO的方式。

多路复用模型select,poll,epoll的对比

从占有的平台来比较:

  • select 是跨平台的 windows、unix、linux下都有
  • polllinux、unix下有,windows下没有
  • epoll 只有linux特有,unix和windows下没有

从函数模型来比较:

select模型

/* @Param:
   nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
   readfds:	监控读数据文件描述符集合,传入传出参数
   writefds:	监控写数据文件描述符集合,传入传出参数
   exceptfds:	监控异常发生文件描述符集合,如带外数据到达异常,传入传出参数
   timeout:	定时阻塞监控时间,3种情况: 
                   1. NULL(永远等下去); 
                   2. 设置timeval,等待固定时间; 
                   3. 设置timeval里时间均为0,检查描述字后立即返回,轮询
 */
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  1. 使用select模型处理IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差,但select模型可以一个线程内同时处理多个socket的IO请求,即如果处理的连接数不是很高的话,使用select的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大,select的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接
  2. 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
  3. select采用的是轮询模型,每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
  4. 为了减少数据拷贝以及轮询fd_set带来的性能损坏,内核对被监控的fd_set集合大小做了限制,这个是通过宏FD_SETSIZE控制的,一般32位平台为1024,64位平台为2048,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数

poll模型

/*  @Param
	struct pollfd {
		int fd;           /* 文件描述符 */
		short events;     /* 监控的事件 */
		short revents;    /* 监控事件中满足条件返回的事件 */
	};

*/

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  1. poll本质上和select没有区别,只是它没有最大连接数的限制,原因是它是基于链表来存储的,它将用户传入的需要监视的文件描述符拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd
  2. poll只解决了select监视文件描述符数量的限制,并没有改变每次调用都需要将文件描述符从程序空间(用户空间)拷贝到内核空间和内核底层轮询所有文件描述符带来的性能开销

epoll模型

/* 创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,跟内存大小有关 */
int epoll_create(int size);

/* 控制某个epoll监控的文件描述符上的事件:注册、修改、删除 */
/*  @Param
    epfd:	epoll_creat的句柄
    op:	表示动作,用3个宏来表示:
			EPOLL_CTL_ADD (注册新的fd到epfd),
			EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
			EPOLL_CTL_DEL (从epfd删除一个fd);
    event:	告诉内核需要监听的事件
            struct epoll_event {
			    __uint32_t events; // Epoll events 
			    epoll_data_t data; // User data variable 
		    };
			typedef union epoll_data {
				void *ptr;
				int fd;
				uint32_t u32;
				uint64_t u64;
			} epoll_data_t;
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/* 等待所监控文件描述符上有事件的产生 */
/*  @Param
    epfd:	    epoll_creat的句柄
    events:	用来存内核得到事件的集合
	maxevents:	告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
	timeout:	是超时时间
			    -1:	阻塞
			     0:	立即返回,非阻塞
			    >0:	指定毫秒
    返回值:	    成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
  1. epoll是Linux下IO多路复用select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了(采用回调机制,不是轮询的方式,不会随着FD数目的增加效率下降,只有活跃可用的FD才会调用callback函数)

  2. 虽然表面看起来epoll非常好,但是对于连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,因为epoll是建立在大量的函数回调的基础之上

  3. epoll除了提供select/poll那种IO事件的水平触发(Level Triggered,水平触发只要有数据都会触发)外,还提供了边沿触发(Edge Triggered,边缘触发只有数据到来才触发,不管缓存区中是否还有数据),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率

             2. 设置timeval,等待固定时间; 
                3. 设置timeval里时间均为0,检查描述字后立即返回,轮询
    

*/
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);


1. 使用select模型处理IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差,但select模型可以一个线程内同时处理多个socket的IO请求,即如果处理的连接数不是很高的话,使用select的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大,**select的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接**
2. 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
3. select采用的是**轮询模型**,每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
4. 为了减少数据拷贝以及轮询fd_set带来的性能损坏,内核对被监控的fd_set集合大小做了限制,这个是通过宏FD_SETSIZE控制的,一般32位平台为1024,64位平台为2048,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数

### poll模型

```java
/*  @Param
	struct pollfd {
		int fd;           /* 文件描述符 */
		short events;     /* 监控的事件 */
		short revents;    /* 监控事件中满足条件返回的事件 */
	};

*/

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  1. poll本质上和select没有区别,只是它没有最大连接数的限制,原因是它是基于链表来存储的,它将用户传入的需要监视的文件描述符拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd
  2. poll只解决了select监视文件描述符数量的限制,并没有改变每次调用都需要将文件描述符从程序空间(用户空间)拷贝到内核空间和内核底层轮询所有文件描述符带来的性能开销

epoll模型

/* 创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,跟内存大小有关 */
int epoll_create(int size);

/* 控制某个epoll监控的文件描述符上的事件:注册、修改、删除 */
/*  @Param
    epfd:	epoll_creat的句柄
    op:	表示动作,用3个宏来表示:
			EPOLL_CTL_ADD (注册新的fd到epfd),
			EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
			EPOLL_CTL_DEL (从epfd删除一个fd);
    event:	告诉内核需要监听的事件
            struct epoll_event {
			    __uint32_t events; // Epoll events 
			    epoll_data_t data; // User data variable 
		    };
			typedef union epoll_data {
				void *ptr;
				int fd;
				uint32_t u32;
				uint64_t u64;
			} epoll_data_t;
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/* 等待所监控文件描述符上有事件的产生 */
/*  @Param
    epfd:	    epoll_creat的句柄
    events:	用来存内核得到事件的集合
	maxevents:	告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
	timeout:	是超时时间
			    -1:	阻塞
			     0:	立即返回,非阻塞
			    >0:	指定毫秒
    返回值:	    成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
  1. epoll是Linux下IO多路复用select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了(采用回调机制,不是轮询的方式,不会随着FD数目的增加效率下降,只有活跃可用的FD才会调用callback函数)
  2. 虽然表面看起来epoll非常好,但是对于连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,因为epoll是建立在大量的函数回调的基础之上
  3. epoll除了提供select/poll那种IO事件的水平触发(Level Triggered,水平触发只要有数据都会触发)外,还提供了边沿触发(Edge Triggered,边缘触发只有数据到来才触发,不管缓存区中是否还有数据),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值