Java基础

什么是字节码?使用字节码的好处。

  • JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件)。字节码不面向任何特定的处理器,只面向虚拟机,因此Java程序无需重新编译就可以在不同操作系统的计算机上运行。一定程度上解决了解释型语言执行效率低的问题,又保留了解释型语言可移植的特点。

为什么说 Java 语言“编译与解释并存”?

  • 因为Java语言既具有编译型语言的特征,也具有解释型语言的特征。Java 程序要经过先编译,后解释两个步骤,由Java编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。

AOT 有什么优点?为什么不全部使用 AOT 呢?

  • 新的编译模式 AOT 的主要优势在于启动时间、内存占用和打包体积。AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。

Java 中的几种基本数据类型

  • 6 种数字类型:
    • 4 种整数型:byte(8)、short(16)、int(32)、long(64)
    • 2 种浮点型:float(32)、double(64)
  • 1 种字符类型:char(16)
  • 1 种布尔型:boolean(1)

包装类型的缓存机制

  • 八种基本类型都有对应的包装类分别为:ByteShortIntegerLongFloatDoubleCharacterBoolean
  • Byte, Short, Integer, Long 这4种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False
  • Float, Double 并没有实现缓存机制。
  • 所有整型包装类对象之间值的比较,全部使用 equals 方法比较。

自动装箱与拆箱的原理是什么

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;
  • 装箱其实就是调用了包装类的valueOf()方法,拆箱其实就是调用了xxxValue()方法。

为什么浮点数运算的时候会有精度丢失的风险?如何解决。

  • 计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。
  • BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。

静态变量有什么作用?

  • 静态变量只会被分配一次内存,可以被类的所有实例共享。

静态方法为什么不能调用非静态成员?

  • 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
  • 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。

重载和重写有什么区别?

  • 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理。发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
  • 重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法。发生在运行期。
    • 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
    • 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
    • 构造方法无法被重写

什么是可变长参数?

  • 所谓可变长参数就是允许在调用方法时传入不定长度的参数。可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。

      public static void method1(String... args) {
          //......
      }
    
  • 遇到方法重载,会优先匹配固定参数。

面向对象和面向过程的区别

  • 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
  • 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题

面向对象三大特征

  • 封装 把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。
  • 继承 继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。
  • 多态 一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

接口和抽象类有什么共同点和区别?

  • 共同点:
    • 都不能被实例化。
    • 都可以包含抽象方法。
    • 都可以有默认实现的方法。
  • 区别:
    • 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
    • 一个类只能继承一个类,但是可以实现多个接口。
    • 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。

深拷贝,浅拷贝和引用拷贝

--- ## `hashCode( )` 有什么用? - `hashCode()`的作用是获取哈希码,确定该对象在哈希表中的索引位置。
  • 当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals() 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
  • 如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
  • 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。
  • 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。

为什么重写 equals() 时必须重写 hashCode() 方法?

  • 因为两个相等的对象的 hashCode值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
  • 如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。

StringStringBufferStringBuilder 的区别?

  • 可变性 String是不可变的,因为String类中使用final关键字修饰字符数组来保存字符串,且String类被final修饰导致其不能被继承。
  • 线程安全性
    • String 中的对象是不可变的,也就可以理解为常量,线程安全。
    • StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
    • StringBuilder并没有对方法进行加同步锁,所以是非线程安全的。
  • 性能
    • 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String对象。
    • StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。
    • 相同情况下使用 StringBuilder 相比使用 StringBuffer仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

字符串常量池的作用

  • 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

intern 方法有什么作用?

  • String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
    • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
    • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

String类型进行+拼接时发生什么?

  • 创建一个StringBuilder对象,通过append方法添加,然后toString

异常

  • ExceptionError 有什么区别?
    • Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception(受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
      • Checked Exception: 除了RuntimeException及其子类以外,其他的Exception类及其子类。
      • Unchecked Exception: RuntimeException 及其子类
    • ErrorError 属于程序无法处理的错误 ,我们不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
  • try-catch-finally 如何使用?
    • try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
    • catch块:用于处理 try 捕获到的异常。
    • finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

泛型

  • 泛型是指参数化类型,即将类型进行参数化。在泛型使用过程中,操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
  • 泛型类:实例化时必须指定类型。
  • 泛型接口:实现泛型接口时,可以指定类型,也可以不指定类型。
  • 泛型方法。

反射

  • 通过反射,我们可以在运行时获得程序或程序集中每一个类型的成员和成员的信息。程序中一般的对象的类型都是在编译期就确定下来的,而 Java 反射机制可以动态地创建对象并调用其属性,这样的对象的类型在编译期是未知的。所以我们可以通过反射机制直接创建对象,即使这个对象的类型在编译期是未知的。
  • 优点

    • 可扩展性 :应用程序可以利用全限定名创建可扩展对象的实例,来使用来自外部的用户自定义类。
    • 类浏览器和可视化开发环境 :一个类浏览器需要可以枚举类的成员。可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码。
    • 调试器和测试工具 : 调试器需要能够检查一个类里的私有成员。测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率。
  • 缺点

    • 性能开销 :反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。我们应该避免在经常被执行的代码或对性能要求很高的程序中使用反射。
    • 安全限制 :使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如 Applet,那么这就是个问题了。
    • 内部暴露 :由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。
  • 应用场景:IDE可以自动列出类的属性或方法;开发框架;注解。

注解

  • 可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
  • 注解只有被解析之后才会生效,常见的解析方法有两种:
    • 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用@Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
    • 运行期通过反射处理:像框架中自带的注解(比如 Spring 框架的 @Value@Component`)都是通过反射来进行处理的。

SPI

  • Service Provider Interface,提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
  • SPI与API的区别:SPI的接口存在于调用方,而API的接口存在于实现方。
  • SPI 的优缺点
    • 优点:通过 SPI 机制能够大大地提高接口设计的灵活性
    • 缺点:
      • 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
      • 当多个 ServiceLoader 同时 load 时,会有并发问题。

序列化和反序列化

  • 序列化:将数据结构或对象转换成二进制字节流的过程
  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
  • 序列化协议属于 TCP/IP 协议应用层的一部分。
  • 对于不想进行序列化的变量,使用 transient 关键字修饰。

I/O

  • InputStream/OutputStream:字节输入/输出流
  • Reader/Writer:字符输入/输出流
  • BufferedInputStream/BufferedOutputStream:字节缓冲输入/输出流

BIO,NIO, AIO

  • BIO: 属于同步阻塞IO,应用程序发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
  • NIO: 非阻塞,支持面向缓冲,基于通道的IO,以块的方式处理数据,一次处理一个数据块。
  • AIO: 应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

语法糖

  • 语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。
  • JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。
  • Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。

Java代理模式

  • 使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。

Java的四种引用类型

  • 强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候
  • 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收
  • 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
  • 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知

Java集合

Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。


List, Set, Queue, Map 四者的区别。

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
  • Set(注重独一无二的性质): 存储的元素不可重复的。
  • Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
  • Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),”x” 代表 key,”y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

集合框架底层数据结构总结

  • List
    • ArrayList:基于数组,可随机访问,线程不安全。
    • Vector:与ArrayList相似,但是线程安全。
    • LinkedList:基于双向链表,只能顺序访问,但可以快速插入和删除元素。还可以用作栈、队列和双向队列。
  • Set
    • HashSet:基于HashMap实现,底层采用HashMap保存元素。
    • LinkedHashMap:HashSet的子类,底层通过LinkedHahMap实现。
    • TreeSet:底层采用红黑树实现,有序。
  • Queue
    • PriorityQueue:基于堆结构实现,优先队列。
  • Map
    • HashMap:JDK1.8之前采用数组+链表实现,JDK1.8后,当链表长度大于阈值时,会转换为红黑树。
    • LinkedHashMap:在HaspMap的基础上加入双向链表,保持键值对的插入顺序。
    • HashTable:数组+链表,但是线程安全。但是是遗留类,通常使用ConcurrentHashMap来支持线程安全。
    • TreeMap:底层是红黑树。

ArrayList 与 LinkedList 区别

  • 是否保证线程安全ArrayListLinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构。
  • 插入和删除是否受元素位置影响:在指定位置插入或删除时受影响;
  • 是否支持快速随机访问LinkedList不支持,ArrayList支持;
  • 内存空间占用ArrayList预留,LinkedList要存放前驱指针和后继指针。

Comparable 和 Comparator 的区别。

  • Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用:
    • Comparable 接口实际上是出自java.lang包它有一个compareTo(Object obj)方法用来排序
    • Comparator接口实际上是出自 java.util包它有一个compare(Object obj1, Object obj2)方法用来排序

BlockingQueue

  • BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。

HashMap 和 Hashtable 的区别

  • 线程是否安全HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。
  • 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
  • 对 Null key 和 Null value 的支持HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
  • 初始容量大小和每次扩充容量大小的不同
    • 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    • 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间。Hashtable 没有这样的机制。

HashMap

JDK1.8之前,通过数组+链表实现;JDK1.8之后,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树。

  • hash(扰动函数)

      static final int hash(Object key) {
          int h;
          return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
      }
    
  • put

    • 找到hash相等、key相等的节点
  • get

    • 找到hash相等、key相等的节点
  • resize

    • 判断oldCap是否大于0,如果大于0,还要判断oldCap是否大于允许的最大容量;判断旧阈值是否大于0,如果大于0则newCap=oldThr;否则,设置为默认值。
    • 如果newThr==0,那么为newThr赋值,需要判断newCap和ft是否小于允许的最大容量,不满足的话设为MAX_VALUE。
    • 对原table中的节点进行移动,需要判断hash&oldCap是否为0。
  • remove

    • 寻找table下标,如果当前节点是要删除的,直接赋值;遍历链表去寻找。

HashMap 的长度为什么是 2 的幂次方

  • 为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作

为什么负载因子默认是0.75

  • 泊松分布,负载因子为0.75的情况下,节点出现在某个桶的频率遵循λt=0.5的泊松分布,那么链表长度达到八个元素的概率很小。所以这也是链表长度阈值为8的原因。

ConcurrentHashMap 和 Hashtable 的区别

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
  • 实现线程安全的方式:JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。JDK1.8中,底层结构为数组+链表/红黑二叉树,通过synchronized和CAS实现并发控制。此时锁的粒度更细,只锁定当前节点。

Java并发

什么是线程和进程

  • 进程 是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
  • 线程 与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的 堆和方法区 资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

线程的生命周期和状态

  • NEW: 初始状态,线程被创建出来但没有被调用 start() 。
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

什么是线程死锁?如何避免死锁

  • 死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
  • 预防死锁:
    • 破坏请求与保持条件:一次性申请所有的资源。
    • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
    • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
  • 避免死锁 就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

sleep() 方法和 wait() 方法对比

  • 共同点:两者都可以暂停线程的执行。
  • 区别
    • sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
    • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
    • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
    • sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

为什么 wait() 方法不定义在 Thread 中?

  • wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。
  • sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

  • new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start()会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

Java 常见并发容器

  • ConcurrentHashMap : 线程安全的 HashMap
  • CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。
  • ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
  • BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。

JMM(Java 内存模型)

volatile 关键字

  • 保证变量的可见性。
  • 防止 JVM 的指令重排序。
  • 不能保证对变量的操作是原子性的。

乐观锁和悲观锁

  • 悲观锁:共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
  • 乐观锁: 总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
  • 乐观锁实现:
    • 版本号机制:在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
    • CAS(Compare And Swap) 算法:CAS 是一个原子操作,涉及到三个操作数:
      • V:要更新的变量值(Var)
      • E:预期值(Expected)
      • N:拟写入的新值(New)
  • 乐观锁 存在哪些问题
    • ABA 问题:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。
    • 循环时间长开销大
    • 只能保证一个共享变量的原子操作

synchronized 关键字

  • 主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。通过monitorenter和moniterexit指令实现,分别指向同步代码块的开始位置和结束位置。
  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;synchronized 关键字加到实例方法上是给对象实例上锁;
  • 构造方法不能使用 synchronized 关键字修饰。

synchronized 和 volatile 有什么区别?

  • volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock

  • ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

公平锁和非公平锁有什么区别?

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronized 和 ReentrantLock 有什么区别?

  • 两者都是可重入锁
  • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
  • ReentrantLocksynchronized 增加了一些高级功能:等待可中断, 可实现公平锁, 可实现选择性通知(锁可以绑定多个条件)。

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁

ThreadLocal

  • ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
  • 最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。
  • 每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。
  • ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

线程池

  • 线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
  • 使用线程池的好处:
    • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
    • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
    • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
  • ThreadPoolExecutor 3 个最重要的参数:
    • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
    • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
    • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中
  • 线程池的饱和策略
    • 当前同时运行的线程数到达最大线程数量且队列满
    • AbortPolicy:抛出异常以拒绝新任务的处理。
    • CallerRunsPolicy:调用执行自己的线程执行任务。
    • DiscardPolicy:不处理任务,直接丢弃。
    • DiscardOldestPolicy:丢弃最早未处理的任务请求。
  • 线程池常用的阻塞队列
    • 容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
    • SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
    • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。
  • 线程池处理任务的流程
  • 上下文切换:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
  • 如何设定线程池的大小?
    • 线程池小:大量任务排队等待,任务队列满,任务堆积导致OOM
    • 线程池大:大量线程争取CPU资源,上下文切换过多
    • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
    • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
  • 如何设计一个能够根据任务的优先级来执行的线程池?
    • 考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。
    • 要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
      • 提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。
      • 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则(推荐)。
    • 存在一些风险和问题,比如:
      • PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。
      • 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
      • 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。

Future

  • Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。
  • Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
    • 取消任务;
    • 判断任务是否被取消;
    • 判断任务是否已经执行完成;
    • 获取任务执行结果。

AQS

  • AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。主要用来构建锁和同步器。

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

      CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。
    • 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() 函数返回,继续后余动作。
  • Semaphore
    • synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。
    • Semaphore有两种模式:。
      • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
      • 非公平模式: 抢占式的。
    • 原理
      • Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。
      • 调用semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。
      • 调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。
  • CountDownLatch
    • CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。-
    • CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
    • 原理
      • `CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行
    • 使用场景
      • 读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
  • CyclicBarrier
    • CyclicBarrierCountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。
    • CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。
    • CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
    • 原理
      • CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。