面试八股之Java基础+集合+多线程+JVM

文章目录

一、Java基础

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

  • 面向过程的性能优于面向对象。因为类调用时需要实例化,开销比较大,比较消耗资源。在一些对性能要求比较高的场景,如嵌入式开发、单片机、Linux/Unix开发等,一般采用面向过程。
  • 面向对象比面向过程更容易维护、复用和扩展。面向对象的三大特性:封装、继承和多态,使得面向对象可以设计出低耦合的系统,使得系统更加灵活、易于维护。
  • 面向过程也需要分配内存和计算偏移量,Java性能差的主要原因不是因为它是面向对象,而是Java属于半编译语言,最终执行的代码并不是可以被CPU直接运行的二进制机械码。

1.2 Java语言有哪些特点

  1. 简单易学
  2. 面向对象(封装、继承、多态)
  3. 平台无关性(Java虚拟机实现平台无关性)
  4. 可靠性
  5. 安全性
  6. 支持多线程(C++没有内置的多线程机制,只能通过调用操作系统的多线程机制来进行多线程设计)
  7. 支持网络编程
  8. 编译和解释共存

1.3 JVM、JDK和JRE

  • JVM:Java虚拟机,是运行Java字节码的虚拟机。JVM针对不同的操作系统有不同的实现,目的是使用不同的字节码,都会给出相同的结果。JVM是Java语言实现“一次编译,随处运行”的关键所在。
  • JDK:Java开发工具包,是功能齐全的JAVA SDK,拥有JRE所拥有的的一切,还有编译器javac和工具javadoc和jdb。它能够创建和编译程序。
  • JRE:Java运行时环境,是运行已编译Java的所需的所有内容的集合,包括JVM,Java类库,java命令和其他的一些基础构件。但是,它不能用于创建新程序。

1.4 什么是字节码

字节码是Java编译后的.class文件,可以在JVM上运行的代码。它不面向任何特定的机器,只面向JVM。Java通过字节码的形式,在一定程度上解决了传统解释型语言执行效率低的问题,同时也保留了解释型语言可移植的特点。

Java程序从源代码到执行一般分为三步:
在这里插入图片描述
Java.class文件转换到二进制机器码这一步,JVM通过类加载器首先加载字节码文件,然后逐行解释执行,这样的效率是很低的,而且有些代码块会经常被调用(热点代码),所以后面引进了JIT编译器,而JIT编译器是运行时编译。JIT编译器完成一次编译后,将字节码对应的机器码保存下来,供后续直接调用。这就是Java属于编译与解释共存的原因所在。
HopSpot采用了惰性评估的方法,估计二八定律,消耗系统大部分资源的代码往往是下部分代码。所以JIT编译器通过对这部分代码进行编译,提高了代码的执行效率。JVM会根据每次执行的情况作出优化,所以执行的次数越多,执行速度越快。JDK 9中引入了AOT(Ahead of Time Compilation)编译模式,它直接将字节码编译成机器码,这就避免了JIT预热等方面的开销。JDK支持分层编译和AOT编译协同工作,并且AOT的编译质量是不如JIT的。

1.5 Java和C++的区别

  • C++和JAVA都是面向对象语言,具有三大特性
  • Java不提供指针来访问内存,程序内存更加安全
  • Java类是单继承的,C++类是多继承的。虽然Java的类不是多继承,但是可以通过接口实现多继承
  • Java有自动内存管理机制,不需要程序员手动释放无用内存
  • C/C++字符串或字符数组最后都有一个额外的字符‘\0’来表示结束,Java中没有结束符这一概念【参考https://blog.csdn.net/sszgg2006/article/details/49148189

1.6 字符型常量和字符串常量的区别

  • 形式上:字符型常量是用单引号,字符串常量是用双引号
  • 含义上:字符型常量相当于一个整型(ASCII值),可以参加表达式计算。字符串常量相当于一个内存地址
  • 内存大小:字符型常量占两个字节,字符串常量占若干个字节
    在这里插入图片描述

1.7 构造器Constructor是否可以被Override

构造器不可以被重写,但可以被重载。

1.8 重载和重写的区别

  • 重载就是同⼀个类中多个同名⽅法根据不同的传参来执⾏不同的逻辑处理。
  • 重写发⽣在运⾏期,是⼦类对⽗类的允许访问的⽅法的实现过程进⾏重新编写。
    在这里插入图片描述

1.9 Java面向对象编程三大特性

  • 封装
    封装是把一个对象的属性私有化,然后提供一些可以被外界访问属性的方法,如果属性不想被外界访问,可以不提供方法,但是如果一个类没有提供给外界任何访问的方法,那么这个类也就没什么意义了。
  • 继承
    继承是在已存在的类基础上定义新的类,新的类被称为子类,被继承的类叫做父类,子类拥有父类的所有属性和方法(但对私有属性和方法不能使用,只是拥有),也可以扩展新的功能或者改写父类的方法(重写)。
  • 多态
    多态是指同一个行为具有不同的形式或表现形态的能力。在程序运行时才能确定引用类型所指向的具体类实例,继承和接口实现了多态。

1.10 StringBuffer和StringBuilder的区别是什么?String为什么是不可变的?

String类中有个属性char[] value用来保存字符数组,但是该属性是用final关键字修饰的,所以String对象是不可变的。(Java9后,String类的实现改用byte数组存储字符串,private final byte[] value)

而StringBuffer和StringBuilder都是继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组来保存字符串的,但是没有final关键字修饰,所以这两种对象都是可变的。

String对象是不可变的,可以理解为常量,线程安全。StringBuffer对方法添加了同步锁,所以是线程安全的。StringBuilder并没有对方法加同步锁,所以是非线程安全的。

性能:

  • 对String进行改变时,会生成一个新的String对象,然后将指针指向新的String对象。StringBuffer每次会对StringBuffer对象本身进行操作,而不是生成新的对象并改变对象引用。
  • 相同情况下使用StringBuilder相比StringBuffer仅能获得10-15%的性能提升,但却要冒多线程不安全的风险。

总结:

  • 操作少量的数据:String
  • 单线程操作字符串缓冲区下操作大量数据:StringBuilder
  • 多线程操作字符串缓冲区下操作大量数据:StringBuffer

1.11 自动装箱和拆箱

  • 装箱:将基本类型用对应的引用类型包装起来;
  • 拆箱:将包装类型转为基本数据类型;

1.12 在一个静态方法内调用一个非静态成员为什么是非法的?

由于静态方法可以不通过对象调用,因此在静态方法里,不能调用其它非静态变量,也不可以访问非静态变量成员。

1.13 在Java中定义一个不做事且没有参数的构造方法的作用

Java程序执行中,如果子类没有调用super方法来调用父类特定的构造方法,那么会自动在父类中寻找并调用无参构造,如果父类中没有无参构造,编译时就会报错,所以需要在父类中定义一个不做事且没有参数的构造方法。

1.14 接口和抽象类的区别是什么?

  • 接口中没有具体的方法,抽象类中可以有具体的方法
  • 接口中除了static、final变量,不能有其它变量,抽象类不一定
  • 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过extends关键字拓展多个接口
  • 接口方法默认修饰符是public,抽象类方法可以有public、protected、default这些修饰符(抽象方法就是为了被重写所以不能使用private关键字修饰!)
  • 从设计层面来说,抽象类是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。

JDK7~9中接口的变化

  1. jdk7或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现
  2. jdk8的接口可以有默认方法和静态方法
  3. jdk9的接口引入了私有方法和私有静态方法

1.15 成员变量与局部变量的区别有哪些?

  1. 从语法形式上看:成员是属于类的,用public、private、static等关键字修饰,局部变量是属于方法的,不能用关键字修饰(final除外)
  2. 从内存上看:如果成员变量是使用staic修饰的,那么这个成员变量是属于类的,如果没有使用static修饰,那么这个成员变量是属于实例的。对象存于堆内存,如果局部变量是基本数据类型,那么存储在栈内存,如果为引用类型,那存放的是指向堆内存对象的引用或指向常量池的地址
  3. 从生存时间上看:成员变量随对象的创建而存在,而局部变量随着方法的调用而自动消失
  4. 从初始化上看:成员变量如果没有被赋值,会自动以类型的默认值而赋值,而局部变量则不会自动赋值

1.16 创建⼀个对象⽤什么运算符?对象实体与对象引⽤有何不同?

创建一个对象用new运算符。new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存在栈内存中)。一个对象引用可以指向0或1个对象(一根绳子可以不系气球或系一个气球);一个对象可以有n个引用指向它(一个气球可以系n条绳子)。

1.17 什么是⽅法的返回值?返回值在类的⽅法里的作用是什么?

返回值是指我们调用某个方法中代码执行后产生的结果;作用是可以使它用于其他操作。

1.18 一个类的构造方法的作用是什么?若一个类没有声明构造⽅法,该程序能正确执⾏吗? 为什么?

完成对类对象的初始化。可以,因为即使没有声明构造方法,也有默认的无参构造

1.19 构造方法有哪些特性?

  1. 名字与类名一致
  2. 没有返回值,但不能用void声明构造参数
  3. 生成类的对象时自动执行,无需调用

1.20 静态方法和实例方法有何不同?

  1. 静态方法可以通过类名或对象名调用,实例方法只能通过对象名调用
  2. 静态方法访问本类成员时,只允许访问静态成员(静态变量和静态方法),不允许访问实例成员变量和实例方法

1.21 对象的相等与指向它们的引用相等,两者有什么不同?

对象的相等比的是内存中存放的内容是否相等,而引用相等比的是它们指向的内存地址是否相等。

1.22 == 与 equals(重要)

  • == 的作用是判断两个对象的地址是否相等。在对基本类型进行比较时,比较的是两者的值,而在引用类型进行比较时,比较的不仅有它的值还有内存地址。
  • equals()的作用也是判断两个对象是否相等,但它一般有两种使用情况:
    1. 情况1:类没有覆盖equals的方法,则通过equals比较该类的两个对象时,等价于通过“==”比较两个对象
    2. 情况2:类覆盖了equals的方法,一般我们都将其覆盖成 比较两个对象的内容是否相等,若他们内容即返回true

1.23 hashCode与equals(重要)

1.24 为什么Java中只有值传递?

Java程序中对象采用的不是引用调用,而是按值传递的。方法传递的是对象的拷贝,拷贝和原型指向同一块空间,但是在进行修改时,修改的是拷贝的引用指向,原型指向并不发生变化。

1.25 简述程序、进程、线程的基本概念,以及它们之间的关系

  • 程序是指计算机中为完成某项任务的代码指令的集合,是静态的概念。
  • 进程是动态的概念,是程序执行的过程,是资源分配的单位
  • 线程是是CPU调度和执行的单位,一个进程可以分成多个线程。

1.26 线程的基本状态

状态名称说明
NEW初始状态,线程被构建,还没调用start()方法
RUNNABLE运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称作“运行中”
BLOCKED阻塞状态,表示线程阻塞于锁
WAITING等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定的动作,如通知或中断
TIME_WAITING超时等待状态,该状态不同于WAITING,它是可以在指定的时间内自行返回的
TERMINATED终止状态,表示当前线程已经执行完毕

在这里插入图片描述
当线程创建后它将处于New(新建状态),调用start()方法后开始运行,进入Ready(可运行状态),获得CPU时间片后,就处于了Running(运行状态)。调用wait()方法后进入Waiting(等待状态),只有在获得其它线程通知的情况下才能返回运行状态;如果调用Sleep(long millis)方法或wait(long millis)方法则会进入超时等待状态,当超时时间到达或收到其他线程通知,会返回运行状态。当线程调用同步方法时,在没有获得锁的情况下,线程会进入Blocked(阻塞状态)。线程执行Runnable的run()方法后,会进入到Terminated(终止状态)。

1.27 关于final关键字的一些总结

  • final关键字可以用来修饰变量、方法和类
  • final变量如果是基本数据类型,那么其数值一旦初始化便不可再修改;如果是引用数据类型,初始化之后不能再让其指向另一个对象。
  • final类不能被继承,类中所有的成员方法被隐式指定为final方法
  • 使用final方法的原因有两个,一是把方法锁定,以防任何继承类修改它的含义;二是效率,在早期java版本中,会将final方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升。

1.28 Java序列化中如果有些字段不想进行序列化,怎么办?

可以用transient修饰。阻止实例中用此修饰的变量序列化,反序列化时阻止其持久化和恢复。transient只能修饰变量,不能修饰方法和类。

1.29 获取用键盘输入常用的两种方法

  • 方法一:Scanner
Scanner input = new Scanner(Sysyem.in);
String s = input.nextLine();
input.close();
  • 方法二:BufferReader
BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
String s = input.readLine();

1.30 Java中IO流分为几种?

  • 按流向分类:输入流和输出流
  • 按操作单元分类:字节流和字符流
  • 按角色分类:节点流和处理流

Java IO流的40多个类都是从以下4个抽象类基类中派生出来的:

  • InputStream/Reader: 所有输入流的基类,前者是字节输入流,后者是字符输入流
  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流

1.31 既然有了字节流,为什么还要字符流?

字符流是由Java虚拟机将字节转换得到的,这个过程还是非常耗时的,并且,如果我们不知道编码类型就很容易出现乱码的问题。所以,I/O流干脆就提供了一个直接操作字符的接口,方便我们平时对字符进行操作。如音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

1.32 BIO、NIO、AIO有什么区别?

  • BIO(Blocking I/O):同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的IO并且编程简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是⼀个天然的漏⽃,可以缓冲⼀些系统处理不了的连接或请求。但是,当⾯对⼗万甚⾄百万级连接的时候,传统的 BIO 模型是⽆能为⼒的。因此,我们需要⼀种更⾼效的 I/O 处理模型来应对更⾼的并发量。
  • NIO(Non-Blocking/New I/O):是一种同步非阻塞I/O模式,在Java 1.4中引入了NIO框架,对应java.nio包,提供了Channel, Selector, Buffer等抽象。NIO中的N可以理解为Non-Blocking,不单纯是New。它支持面向缓冲的,基于通道的I/O操作方法。NIO提供了与传统BIO模型中的Socket和ServerSocket相对应的SokcerChannel和ServerSocketChannel,两种不同的套接字通道都支持阻塞和非阻塞两种模式。阻塞模式就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的应用程序,可以使用NIO的非阻塞模式来开发。
  • AIO(Asynchronous I/O):AIO 也就是 NIO 2。在 Java 7 中引⼊了 NIO 的改进版 NIO 2,它是异步⾮阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应⽤操作之后会直接返回,不会堵塞在那⾥,当后台处理完成,操作系统会通知相应的线程进⾏后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在⽹络操作中,提供了⾮阻塞的⽅法,但是 NIO 的 IO ⾏为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程⾃⾏进⾏ IO 操作,IO 操作本身是同步的查阅⽹上相关资料,我发现就⽬前来说 AIO 的应⽤还不是很⼴泛,Netty 之前也尝试使⽤过 AIO,不过⼜放弃了。

1.33 深拷贝VS浅拷贝

  • 浅拷贝:对基本数据类型进行值传递,对引用类型进行引用传递般的拷贝
  • 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容
  • 在这里插入图片描述

1.34 强类型与弱类型

二、Java集合

2.1 说说List、Set、Map三者的区别

  • List:存储的元素是有序的、可重复的
  • Set:存储的元素是无需的、不可重复的
  • Map: 元素是以键值对(key-value)形式存储的,key是无序的、不可重复的,value是无序可重复的。

2.2 ArrayList与LinkedList区别

  • 线程安全:两者都是不同步的,也就是不保证线程安全;
  • 底层数据结构:ArrayList底层使用的是Object数组,LinkedList底层使用的是双向链表
  • 插入和删除是否收元素位置的影响:ArrayList采用数组存储,插入和删除的时间复杂度受元素位置的影响。LinkedList采用链表,插入和删除的时间复杂度不受元素位置的影响
  • 是否支持快速随机访问:ArrayList支持,LinkedList不支持。
  • 内存空间占用:ArrayList的空间浪费主要体现在结尾会预留一定的空间,而LinkedList体现在每个元素都要消耗比ArrayList更多的空间。

2.3 ArrayList的扩容机制

ArrayList底层操作的是Object数组elementData,非线程安全,实现了Serializable接口,可以序列化;实现RandomAccess接口,可以快速随机访问。

  • 在JDK1.8中,如果通过无参构造的话,初始数组容量为0,当真正对数组进行添加时(即添加第一个元素时),才真正分配容量,默认分配容量为10;当容量不足时(容量为size,添加第size+1个元素时),先判断按照1.5倍(位运算)的比例扩容能否满足最低容量要求,若能,则以1.5倍扩容,否则以最低容量要求进行扩容。
  • 如果在实例化时给出初始长度,则初始化为给定的长度,当需要再次扩容时,将扩大为原来的1.5倍。

2.4 ArrayList与Vector的区别

  • ArrayList是List的主要实现类,底层使用Object[]存储,适用于频繁的查找工作,线程不安全
  • Vector是List的古老实现类,底层使用Object[]存储,线程安全

2.5 HashMap和HashTable的区别

  • 线程是否安全:HashMap是非线程安全的,HashTable是线程安全的,因为HashTable的内部方法基本都经过synchronized修饰
  • 效率:由于线程安全的问题,HashMap比HashTable的效率要高一点
  • 对Nullkey和NullValue的支持:HashMap可以存储null的key和value,但只能有一个nullkey,可以有多个nulllvalue。HashTable不允许有nullkey和nullvalue,会抛出NullPointerException。
  • 初始容量和扩容机制不同
    1. 创建时不指定容量初始值,Hashtable默认初始大小为11,之后每次扩容变为原来的2n+1。HashMap默认初始大小为16,之后每次扩容变为原来的2倍。
    2. 创建时指定了容量初始值,那么hashtable会直接使用你给定的大小,而Hashmap会将其扩充为2的幂次大小
  • 底层数据结构:JDK1.8后,HashMap在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),如果当前数组长度小于64,那么会选择先进行数组扩容,然后将链表转为红黑树,以减少搜索时间。Hashtable没有这样的机制。

2.6 HashMap和HashSet的区别

  • HashSet的底层是由hashMap实现的,除了clone、writeObject和readObject三个方法自己实现外,其他方法均是直接使用的HashMap的方法。
HashMapHashSet
实现了Map接口实现了Set接口
存储键值对存储对象
调用put()向map中添加元素调用add()方法向Set中添加元素
HashMap使用键值key计算hashcodeHashSet使用对象来计算hashcode,两个对象hashcode可能相等,所以调用equals方法来判断对象的相等性

2.7 HashSet如何检查重复

插入对象时,会利用hash函数计算对象的hashcode,据此来判断对象的插入位置,并与其他hashcode进行比较,如果没有相符的,认为HashSet中没有这个对象,可以将其加入。如果hashcode相等,这是会调用equals方法判断hashcode相等的对象是否真的相同,如果相同,HashSet就不会让其加入成功。

2.8 HashMap的底层实现

JDK1.8之前HashMap的底层是数组和链表结合在一起的链表散列,HashMap通过key的hashcode经过扰动函数处理后获得hash值,然后通过(n-1)&hash,判断当前元素的存放位置(n代表数组的长度),如果当前位置存在元素的话,就判断该元素与要存元素的hash值以及key是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数就是HashMap的hash方法,可以防止一些实现较差的hashcode()方法出现碰撞。

所谓拉链法就是将链表与数组结合,创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),就会先进行数组的扩容,然后再将链表转为红黑树,以减少搜索时间。

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

为了能让HashMap存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。Hash值的范围值-2147483648到2147483647,前后加起来是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能拿出来直接用的。用之前还要先对数组的长度取模运算,得到的余数才能用来当作存放的位置。这个数组下标的计算方法是(n-1)& hash ,采用二进制位操作&,相对于%,可以提高运算效率,这就是为什么HashMap的长度是2的幂次方。

2.10 HashMap多线程操作导致死循环问题

主要原因是在并发下Rehash会造成元素之间形成一个循环链表。不过JDK1.8后解决了这个问题,但是还是不建议在多线程下使用HashMap,因为多线程下HashMap还是存在其他问题,比如数据丢失。并发环境下推荐使用ConcurrentHashMap。

2.11 ConcurrentHashMap和HashTable的区别

  • 底层数据结构:JDK1.7的ConcurrentHashMap底层采用分段数组+链表。JDK1.8采用同HashMap1.8一样的结构,数组+链表/红黑二叉树。HashTable和JDK1.8以前的HashMap的底层数据结构类似,都是采用数组+链表的形式。数组是HashMap的主体,链表则是为了解决哈希冲突而存在的
  • 实现线程安全的方式
    1. JDK1.7的时候,ConcurrentHashMap(分段锁)对整个桶数组进行了分段,每把锁只锁容器中的一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。到了JDK1.8的时候已经摒弃了分段的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和CAS来操作。
    2. Hashtable(同一把锁):使用synchronized来保证线程安全,效率非常低下,当一个线程访问同步方法时,其它线程也访问同步方法,可能会进入阻塞或轮询状态,如使用put添加元素,另一个线程不能使用put添加元素,也不能使用get,竞争会越来越激烈,效率越低。
    3. JDK1.8 的ConcurrentHashMap不再是Segment 数组 + HashEntry 数组 + 链表,而是Node 数组 + 链表 / 红⿊树。不过,Node只能用于链表的情况,红黑树的情况需要使用TreeNode。当冲突链表达到一定长度时,链表会转成红黑树。

2.12 比较Hashset、LinkedHashSet和TreeSet的异同

  • HashSet是Set接口的主要实现类,HashSet的底层是HashMap,线程不安全,可以存Null
  • LinkedHashSet是HashSet的子类,能够按照添加顺序遍历
  • TreeSet底层使用红黑树,能够按照添加元素的顺序进行遍历,排序方式有自然排序和定制排序。

2.13 如何选用集合?

主要根据集合的特点来选用,比如我们需要根据键值获取对应的元素值时,就用Map接口下的集合,需要排序时选择TreeMap,不需要排序时就选择HashMap,需要保证线程安全就选择ConcurrentHashMap。

当我们只需要存放元素值时,就选择实现Collection接口的集合,需要保证元素唯一时选择实现Set接口的集合,如TreeSet或HashSet,不需要就选择实现List接口的集合,如ArrayList或LinkedList,然后再根据实现这些接口的集合的特点来选用。

三、多线程

3.1 线程、进程和程序(见1.25)

3.2 线程的状态(见1.26)

3.3 线程和进程的区别

在这里插入图片描述
一个进程可以分为多个线程,多个线程共享进程的堆和方法区,但是每个线程又有着各自的程序计数器、虚拟机栈和本地方法栈。

线程是进程划分的更小的运行单位。两者最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程相反。

3.4 程序计数器为什么是私有的?

PC主要有以下两个功能:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如顺序、选择、循环和异常处理
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪了,需要注意的是如果执行的是Native方法,那么程序计数器记录的是undefine地址,只有执行的是Java代码时程序计数器记录的才是下一条指令的地址。

所以,PC主要是为了线程切换后能够恢复到正确的执行位置。

3.5 虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈:每个Java方法被调用执行时都会创建一个栈帧来存储局部变量操作数栈常量池引用等信息。从方法到调用直至完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈出栈的过程
  • 本地方法栈:同虚拟机栈作用类似。虚拟机栈为Java方法服务,二本地方法栈为虚拟机使用到的Native方法服务。在HotSpot虚拟机中两栈合二为一。

所以,为了保证线程中的局部变量不被其他线程访问,虚拟机栈和本地方法栈是线程私有的。

3.6 简单介绍堆和方法区

堆和方法区是线程共享的区域,堆是用来存放创建的对象的,是进程中最大的一块内存。方法区主要是用来存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据。

3.7 并发和并行的区别

  • 并发:同一时间段内,多个任务都在执行(单位时间内不一定同时执行)
  • 并行:单位时间内,多个任务同时执行

3.8 为什么要使用多线程

先从总体上说:

  • 从计算机底层来说:线程可以是看成是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本要远远小于进程。另外,多核CPU时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销
  • 从当代互联网发展趋势来说,现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统的并发能力以及性能。

在深入到计算机底层来探讨;

  • 单核时代:在单核时代多线程主要是为了提供CPU和IO设备的综合利用率。当只有一个线程时,会导致CPU占用时,IO设备空闲,IO设备运行时,CPU设备空闲,这样两者的利用率都是50%;如果有两个线程,交替进行就会大大提高两者的综合利用率
  • 多核时代:多核时代主要是为了提高CPU的利用率,在多核CPU中,如果只有一个线程,那么同一时间只能利用一个CPU核心,其他CPU核心处于空闲状态,如果是多线程,则可以占满多个CPU核心。

3.9 使用多线程可能带来什么问题

并发编程是为了提高程序的执行下效率和运行速度,但并发编程并不是总能提高程序运行速度,而且会带来很多问题,比如内存泄漏、上下文切换、死锁。

3.10 什么是上下文切换?

当前任务执行完CPU时间时就会进入就绪态,让出资源,在切换到另一任务之前,要先保存自己的状态,以便下次在切换会这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

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

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

3.11 什么是死锁,产生的条件

多个线程同时被阻塞,其中一个或多个都在等待某个未被释放的资源,由于线程被无限期阻塞,所以程序不能正常结束。比如线程A和B都需要资源1和2,并且A拥有1,想要2,B拥有2,想要1,两者都不肯先放弃手中的资源,就形成了死锁。

死锁的四个条件:

  • 互斥条件:该资源任一时刻只能被一个线程占用
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才能释放资源
  • 循环等待条件:若干进程之间形成一个头尾相接的循环等待资源关系

3.12 sleep()和wait()方法的异同

  • sleep()方法时Thread类的静态方法,wait()方法时Object类的方法,所有类都可以调用
  • 两者最大的区别在于:sleep()方法没有释放锁,而wait()方法释放了锁
  • 两者都可以暂停线程的执行
  • wait()方法通常被用于线程间通信,sleep()通常用于暂停执行
  • wait()方法被调用后,不会自动苏醒,需要其他线程调用notify()或notifyAll()方法唤醒,或者使用wait(long timeout)超时后线程自动苏醒。sleep()执行完成后,线程会自动苏醒。

3.13 为什么我们不直接调用run()来创建多线程,而是用start()

当我们new一个Thread后,线程进入了新建状态,调用start()方法会启动一个线程并使之进入就绪状态,同时进行相应的准备工作,当获得时间片后就开始工作,自动执行run()方法内容,这才是真正的多线程。但是执行run()方法只会将其当作main()下的普通方法去执行,并不是多线程工作。

3.14 谈谈对synchronized关键字的了解

synchronized关键字解决的是多线程之间访问资源的同步性,synchronized可以保证被它修饰的方法或者代码块在任一时刻只能有一个线程执行。

早期Java版本中,synchronized是一个重量级锁。因为监视器依赖底层的Mutex Lock来实现,Java线程是映射到操作系统的原生线程上的。如果要挂起或唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个状态转换的时间成本较高。

Java6之后,对JVM层面的synchronized做了较大优化,引入了自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少所操作的开销。

3.15 Synchronized的使用方式

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

3.16 双重校验锁实现对象单例(线程安全)

⾯试中⾯试官经常会说:“单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁⽅式实现
单例模式的原理呗!”

public class Singleton {
	private volatile static Singleton uniqueInstance;
	private Singleton (){}
	public static Singleton getUniqueInstance() {
		// 先判断对象是否已经实例过,没有实例化才进入加锁代码
		if (uniqueInstance == null) {
			// 类对象加锁
			synchronized (Singleton.class) {
				if (uniqueInstance == null) {
					uniqueInstance = new Singleton();
				}
			}
		}
		return uniqueInstance;
	}
}

另外,需要注意 uniqueInstance 采⽤ volatile 关键字修饰也是很有必要。uniqueInstance = new Singleton(); 这段代码其实是分为三步执⾏:

  1. 为uniqueInstance分配内存空间
  2. 初始化uniqueInstance
  3. 将uniqueInstance指向分配的内存地址

但是由于JVM具有指令重排的特性,执行顺序有可能变成1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程T1执行了1和3,此时T2调用getUniqueInstance()后发现uniqueInstance不为空,因此返回uniqueInstance,但此时uniqueInstance还未被初始化。使⽤ volatile 可以禁⽌ JVM 的指令重排,保证在多线程环境下也能正常运⾏。

3.17 构造方法可以使用synchronized关键词修饰么?

先说结论:构造方法不能使用synchronized关键字修饰。
构造方法本身就是线程安全的,不存在同步的构造方法一说。

3.18 synchronized的底层实现

  • synchronized 同步语句块的实现使⽤的是 monitorentermonitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
  • synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。
  • 不过两者的本质都是对 对象监视器 monitor 的获取

3.19 为什么要弄一个CPU高速缓存

类比开发网站后台系统使用的缓存(比如redis),是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。CPU缓存则是为了解决CPU处理速度和内存处理速度不对等的问题。

3.20 讲一下JMM(Java内存模型)

JDK1.2以前,java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别注意的。而在当前的Java内存模型下,线程可以把变量保存在本地内存(比如寄存器)中,而不是直接在主存中进行读写。这就可能导致一个线程在主存中修改了一个变量的值,而另一个线程还在继续使用它的寄存器中的变量值的拷贝,造成数据的不一致。
在这里插入图片描述
要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,
每次使⽤它都到主存中进⾏读取。所以volatile关键字除了防止JVM的指令重排,还有一个重要的作用就是保证变量的可见性。
在这里插入图片描述

3.21 synchronized关键字和volatile关键字的区别

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

3.22 Sychronized和Lock的区别

  • Synchronized是Java内置关键字,可以自动实现锁的获取与释放,Lock是Java接口,不能自动实现同步操作
  • Synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象的发生;而Lock在发生异常时,不能自动释放占有的锁,只能通过unLock()主动释放锁,如果没有很可能发生死锁现象,因此使用Lock时需要在finally块中释放锁。
  • Lock可以让等待锁的线程响应中断,而synchronized却不行,等待的线程会一直等待下去,不能响应中断
  • Lock可以知道有没有成功获得锁,而synchronized却无法办到
  • Lock可以提高多个线程进行读操作的效率,如果资源竞争非常激烈时,Lock性能远远优于synchronized

3.23 ThreadLocal了解么

通常情况下,我们定义的变量可以被任何线程访问和修改,ThreadLocal类可以实现每个线程都有专属的本地变量。如果创建一个ThreadLocal变量,那么访问这个变量的每个线程都有这个变量的本地副本,这也是ThreadLocal变量名的由来。可以使用get()和set()方法来获取默认值或将其更改为当前线程所存的副本的值,从而避免了线程安全问题。

3.24 ThreadLocal原理

  • Thread类中有两个变量,threadLocalsinheritableThreadLocals,它们都是ThreadLocalMap类型的变量,我们可以把ThreadLocalMap理解为ThreadLocal类实现的定制化HashMap。
  • 默认情况下,这两个变量都是null,只有当前线程调用ThreadLocal类的set()和get()方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的set()和get()方法。
  • 最终的变量是放在了当前线程的ThreadLocalMap中,而不是放在ThreadLocal上,可以理解为ThreadLocal只是对ThreadLocalMap进行了封装,传递了变量值.
  • ThreadLocal内部维护的是一个类似Map的ThreadLocalMap数据结构,key为当前的Thread对象,value为Object对象。ThreadLocal类可以通过Thread.currentThread().getMap()访问到该线程的ThreadLocalMap对象

3.25 ThreadLocal的内存泄漏

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

3.26 为什么要使用线程池

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

3.27 实现 Runnable 接⼝和 Callable 接⼝的区别

  • 是否有返回值:Runnable接口没有返回值,Callable接口有返回值
  • 是否返回异常:Runnable接口不会抛出异常,Callable接口可以抛出检查异常
  • 调用方法不同:Runnable接口重写run()方法,Callable接口重写call()方法
  • Runnable可以直接传递给Thread对象执行;Callable不可以,Callable执行可以放在FutureTask中,然后把futureTask传递给Thread执行

3.28 对Future和FutureTask的理解

  • Future:对具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。总的来说Future提过了三个功能:
    • 判断任务是否完成
    • 取消任务
    • 获取返回结果
  • FutureTask:Future是一个接口,无法生成实例,所以有了FutureTask。FutureTask实现了RunnableFuture接口,RunnableFuture接口又实现了Future接口和Runnable接口。所以FutureTask可以当作Runnable来执行,也可以当作Future来获取Callable的返回结果。futureTask.get()执行时如果该任务已经执行完了则直接返回执行结果,如果没有执行完则线程会阻塞在这里,直至任务执行完毕。还可以用get(long timeout, TimeUnit unit)来获取执行结果,如果在指定时间内,还没获取到结果,就直接返回null。
  • 总的来说:Future模式的思想是在子线程进行执行的时候,主线程不阻塞继续执行。等到主线程需要子线程的结果的时候再去获取子线程的结果,此时子线程如果没有执行完成,便会阻塞直至任务完成返回结果。主线程还可以根据一定的业务逻辑去判断是否要取消执行子线程。还可以设置一个超时时间,若阻塞时间超过阈值,子线程还没有执行完成,便直接返回null
    在这里插入图片描述

3.29 执行execute()方法和submit()方法的区别

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

3.30 如何创建线程池

《阿⾥巴巴 Java 开发⼿册》中强制线程池不允许使⽤ Executors 去创建,⽽是通过ThreadPoolExecutor 的⽅式,这样的处理⽅式让写的同学更加明确线程池的运⾏规则,规避资源耗尽的⻛险。

Executors返回线程池对象的弊端如下:

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

方法一: 通过构造方法实现
在这里插入图片描述
方法二:通过Executor框架的工具类Executors来实现

  • FixedThreadPool : 该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: ⽅法返回⼀个只有⼀个线程的线程池。若多余⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。
  • CachedThreadPool: 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。

3.31 ThreadPoolExecutor构造函数重要参数

  • corePoolSize:核心线程数量
  • maximumPoolSize:最大线程数量
  • workQueue:阻塞队列
  • keepAliveTime:存活时间
  • unit:时间单位
  • threadFactory:线程工厂
  • handler:拒绝策略

3.32 ThreadPoolExecutor饱和策略

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

3.33 线程池底层原理

在这里插入图片描述

  1. 在创建线程池后,线程池中的线程数量为0,并不会立即创建线程,而是在调用execute方法后才创建线程。

  2. 当调用executor提交一个新的请求任务后,会做如下判断:

    • 当前运行的线程数量是否小于corePoolSize,如果是便创建一个核心线程来执行请求任务;
    • 当前运行的线程数量是否大于等于corePoolSize,如果是便将该请求任务放入阻塞队列;
    • 当前运行的线程数量是否大于corePoolSize且小于maximumPoolSize,如果是则创建非核心线程来执行此任务(注意不是从阻塞队列中取任务);
    • 当前运行的线程数量是否大于等于maximumPoolSize,如果是则启动拒绝策略来做处理
  3. 当线程任务执行完成后,会从阻塞队列中取下一个任务来执行

  4. 如果一个线程无事可做,超过一定时间(keepAliveTime)后,线程会判断:

    1. 如果当前运行的线程数量大于corePoolSize,这个线程就会被停掉
    2. 线程池所有任务完成后,它最终会收缩到corePoolSize大小

3.34 Atomic原子类

Atomic是指一个操作是不可中断的。即使在多线程情况下,一个操作一旦开始执行,就不会被其他线程干扰。所谓原子类就是具有原子或原子操作特征的类。并发包 java.util.concurrent 的原⼦类都存放在 java.util.concurrent.atomic 下。

3.35 JUC包中的原子类是哪4类

  • 基本类型
    • AtomicInteger:整型原子类
    • AtomicLong:长整型原子类
    • AtomicBoolean:布尔型原子类
  • 数组类型
    • AtomicIntegerArray:整型数组原子类
    • AtomicLongArray:长整型数组原子类
    • AtomicBooleanArray:布尔型数组原子类
  • 引用类型
    • AtomicReference:引用类型原子类
    • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。
    • AtomicMarkableReference:原子更新带有标记位的引用类型
  • 对象的属性修改类型/更新器类型
    • AtomicIntegerFieldUpdater:原⼦更新整形字段的更新器
    • AtomicLongFieldUpdater:原⼦更新⻓整形字段的更新器
    • AtomicReferenceFieldUpdater:原⼦更新引⽤类型字段的更新器

3.36 AQS了解么

AQS(AbstractQueueSynchronizer)抽象队列式同步器,是一个用来构建锁和同步器的框架,可以简单高效地构造出应用广泛的大量的同步器,比如ReentrantLockSemaphoreReentrantReadWriteLockSynchronousQueueFutureTask等等皆是基于AQS的。当然,我们也能利用AQS轻松构造符合我们自己需求的同步器。

3.37 AQS原理

AQS的核心思想是,如果被请求的共享资源空闲,那么将请求线程设置为有效的工作线程,将共享资源设为定状态;如果被请求的共享资源被上了锁,那么便将请求线程阻塞,这时候需要一个能够存放阻塞线程和唤醒线程时分配锁的机制,AQS利用CLH队列来实现这个机制。AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的队列工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
在这里插入图片描述

3.38 AQS对资源的共享方式

  • Exclusive(独占式):只有⼀个线程能执⾏,如 ReentrantLock 。⼜可分为公平锁和⾮公平
    锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • ⾮公平锁:当线程要获取锁时,⽆视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享式):多个线程可同时执⾏,如CountDownLatch 、 Semaphore 、 CountDownLatch 、CyclicBarrier 、 ReadWriteLock
  • ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多
    个线程同时对某⼀资源进⾏读。

3.39 AQS 底层使⽤了模板⽅法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器,一般的方法是这样的:

  1. 使用者继承 AbstractQueueSynchroniezer并重写指定的方法(重写方法很简单,无非是对于共享资源的获取和释放)
  2. AQS组合在自定义的同步组件中实现,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

3.40 AQS 组件总结

  • CountDownLatch(计时器):同步工具类,用来协调多个线程之间的同步。设置一个初始值num,每完成一个线程,便在num上减1,直到num=0,执行wait()后面的操作。(班长在所有学生走后锁门)
  • CyclicBarrier(循环栅栏):让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程一起执行。(集齐七龙珠召唤神龙)
  • Semaphore(信号灯):维护了一个固定数量的许可集,线程通过acquire()阻塞直至获取许可,执行完通过release()释放许可。(停车位问题)

3.41 自旋锁

获取锁时,线程会对一个原子变量循环执行 compareAndSet 方法,直到该方法返回成功时即为成功获取锁。compareAndSet 方法底层是通用 compare-and-swap (下称 CAS)实现的。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。该操作是原子操作。原子性保证了根据最新信息计算出新值,如果与此同时值已由另一个线程更新,则写入将失败。

3.42 自旋锁的缺点

自旋锁实现简单,同时避免了操作系统进程调度和线程上下文切换的开销,但他有两个缺点:

第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。

第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率

3.43 CLH锁

CLH是一个虚拟的双向队列,实际上不存在真正的队列实例,而是保存了结点之间的关联关系。AQS将每个请求共享资源的线程封装成一个CLH锁队列的一个结点来实现锁的分配。

CLH 锁是对自旋锁的一种改进,有效的解决了锁饥饿和性能差两个缺点(见3.42)。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。

3.44 CLH工作流程

每个节点有两部分:所代表的线程和标识是否持有锁的状态变量
在这里插入图片描述

  1. CLH锁初始化时后,Tail指针指向一个状态为false的空节点,如图1
  2. 当第一个线程Thread1(T1)申请资源时,将Tail指向T1,同时返回初始的空节点,T1检查到上一个节点的状态为false,就成功获得锁,可以执行响应的逻辑了,如图2
  3. 当Thread2(T2)申请资源时,Tail指向T2,并返回T1,检查到T1状态为true,无法获取锁,开始轮询上一个结点的状态,如图3
  4. 当T1释放锁,状态设置为false,如图4
  5. T2轮询检查到上一个节点的状态变为false,则成功获取锁,如图5

3.45 节点中的状态变量为什么用 volatile 修饰?可以不用 volatile 吗?

使用 volatile 修饰状态变量不是为了利用 volatile 的内存可见性,因为这个状态变量只会被持有该状态变量的线程写入,只会被队列中该线程的后驱节点对应的线程读,而且后者会轮询读取。因此,可见性问题不会影响锁的正确性。使用volatile是为了解决指令重排的问题

3.46 CLH 锁是一个链表队列,为什么 Node 节点没有指向前驱或后继指针呢?

CLH 锁是一种隐式的链表队列,没有显式的维护前驱或后继指针。因为每个等待获取锁的线程只需要轮询前一个节点的状态就够了,而不需要遍历整个队列。在这种情况下,只需要使用一个局部变量保存前驱节点,而不需要显式的维护前驱或后继指针。

3.47 CLH 优缺点分析

  • 优点
    • 性能优异,获取和释放锁开销小:CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。
    • 公平锁:先入队的线程会先得到锁。
    • 实现简单,易于理解
    • 扩展性强
  • 缺点
    • 自旋操作,当锁持有时间长时会带来较大的cpu开销
    • 功能单一,不改造不能支持复杂的功能

3.48 进程间通信方式

  • (匿名)管道:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 有名管道:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  • 信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  • 消息队列:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 信号:信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信
  • 套接字:套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。

3.49 线程间通信方式

  • 锁机制:包括互斥锁、条件变量、读写锁。互斥锁提供了以排他方式防止数据结构被并发修改的方法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
  • 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理。线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

3.50 虚假唤醒

多线程环境下,有多个线程执行了wait()方法,需要其他线程执行notify()或者notifyAll()方法去唤醒它们,假如多个线程都被唤醒了,但是只有其中一部分是有用的唤醒操作,其余的唤醒都是无用功;对于不应该被唤醒的线程而言,便是虚假唤醒。
比如:仓库有货了才能出库,突然仓库入库了一个货品;这时所有的线程(货车)都被唤醒,来执行出库操作;实际上只有一个线程(货车)能执行出库操作,其他线程都是虚假唤醒。

四、JVM

4.1 JVM内存模型

JDK 1.7及1.7之前的版本:
在这里插入图片描述
JDK 1.8及之后版本
在这里插入图片描述
JDK1.7及之前版本分为线程共享的堆和方法区,线程独占的本地方法栈、虚拟方法栈和程序计数器。

  • 堆:JVM内存中最大的一块区域,唯一的目的是存放对象实例
  • 方法区:用来存放常量、静态变量、类信息、即时编译后的字节码文件等数据
  • 本地方法栈:存放调用Native方法的栈帧
  • 虚拟方法栈:存放Java线程方法的栈帧
  • 程序计数器:控制线程指令的执行过程

JDK1.8 之后方法区替换为直接内存中的元空间

4.2 方法区和永久代的关系

方法区和永久代的关系类似接口和类的关系,类实现接口,永久代是Hotspot虚拟机对虚拟机规范中的方法区的一种实现。

4.3 为什么将方法区(永久代)替换成元空间?

  • JVM中的永久代有一个固定的大小限制,无法调整大小,而元空间使用直接内存,虽然也会发生内存溢出,但出现的概率比使用永久代小得多
  • JDK1.8中,HotSpot和JRockit合并时,JRockit并没有永久代这一概念,合并后没有必要单独设立一个永久代

4.4 Java对象的创建过程

  1. 类加载检查
    new一个对象后,先检查这个指令的参数是否在常量池中存在对应的符号引用,这个符号引用是否被类加载、解析和初始化过。如果没有,执行响应的类加载检查过程。
  2. 分配内存
    通过类加载检查后,为类实例分配内存。有指针碰撞和空闲列表两种方式,如果堆内存规整使用指针碰撞,否则使用空闲列表。并发时使用CAS和TLAB两种方式保证线程安全。
  3. 初始化零值
    为对象属性初始化零值,所以可以不赋值直接使用
  4. 设置对象头
    设置对象头信息,包括对象类型、对象地址、哈希码、GC年龄等
  5. 执行init方法
    从JVM角度看已经完成对象的创建,但在Java程序角度看才刚刚开始,还需要执行init方法,按照程序员设置好的初始化信息进行赋值。

4.5 如何访问对象

  1. 使用句柄
  2. 直接指针

4.6 如何判断对象是否死亡

  1. 引用计数
  2. 可达性分析

4.7 强引用、软引用、弱引用、虚引用

  • 强引用:必不可少的对象引用,实际中用到的大多引用都是强引用。内存不足时,抛出OOM异常,也不会回收这种引用
  • 软引用:有用但不必需的对象引用,内存不足时JVM才会回收该对象
  • 弱引用:非必须的对象引用,JVM垃圾回收时,无论内存足够与否都会被回收
  • 虚引用:不影响对象的生命周期,主要用来跟踪对象被垃圾回收的活动。任何时候都可以被回收
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值