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

可变性
简单的来说:String 类中使用 final 关键字修饰字符数组来保存字符串,private final char value[],所以String 对象是不可变的。

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串char[]value 但是没有用 final 关键字修饰,所以这两种对象都是可变的。
StringBuilderStringBuffer 的构造方法都是调用父类构造方法也就是AbstractStringBuilder 实现的

线程安全性
String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

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

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

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

对于不想进行序列化的变量,使用 transient 关键字修饰。 transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。

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

方法 1:通过 Scanner

  1. Scanner input = new Scanner(System.in);
  2. String s = input.nextLine();
  3. input.close();Copy to clipboardErrorCopied

方法 2:通过 BufferedReader

  1. BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
  2. String s = input.readLine();

2.4.7. 重写 equals 方法有什么要求?

根据散列约定,如果两个对象相同,它们的散列码一定相同,因此如果重写了 equals 方法,必须重写 hashCode 方法,以保证两个相等的对象对应的散列码是相同的。

2.4.8. 抽象类和接口的区别

  • 抽象类的变量没有限制,接口只包含常量,即接口的所有变量必须是 public static final。
  • 抽象类包含构造方法,子类通过构造方法链调用构造方法,接口不包含构造方法。
  • 抽象类的方法没有限制,接口的方法必须是 public abstract 的实例方法.
  • 一个类只能继承一个父类,但是可以实现多个接口。一个接口可以继承多个接口。

3.1.2.反射机制优缺点

  • 优点: 运行期类型的判断,动态加载类,提高代码灵活度。
  • 缺点: 1,性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的 java 代码要慢很多。2,安全问题,让我们可以动态操作改变类的属性同时也增加了类的安全隐患。


3.1.3. 可以得到 Class 类型实例的三种方法。

  1. 第一种方法是对一个对象调用 getClass 方法,获得该对象所属的类的 Class 对象。
  2. 第二种方法是调用静态方法 Class.forName,将类名作为参数,获得类名对应的 Class 对象。
  3. 第三种方法是对任意的 Java 类型 T(包括基本数据类型、引用类型、数组、关键字 void),调用 T.class 获得类型 T 对应的 Class 对象,此时获得的 Class 对象表示一个类型,但是这个类型不一定是一种类。

三种方法中,通过静态方法 Class.forName 获得 Class 对象是最常用的。

3.1.4. Class 类的常用方法

Class 类中最常用的方法是 getName,该方法返回类的名字。

Class 类中的 getFields、getMethods 和 getConstructors 方法分别返回类中所有的公有(即使用可见修饰符 public 修饰)的数据域、方法和构造方法。

Class 类中的 getDeclaredFields、getDeclaredMethods 和 getDeclaredConstructors 方法分别返回类中所有的数据域、方法和构造方法(包括所有可见修饰符)。

Class 类中的 getField、getMethod 和 getConstructor 方法分别返回类中单个的公有(即使用可见修饰符 public 修饰)的数据域、方法和构造方法。

Class 类中的 getDeclaredField、getDeclaredMethod 和 getDeclaredConstructor 方法分别返回类中单个的数据域、方法和构造方法(包括所有可见修饰符)。

3.1.5 创建对象的方式

  1. 通过构造器 new 对象
  2. 通过反射
  3. 通过反序列化
  4. 克隆
  5. IOC : 容器创建
  6. 动态代理模式

3.2.1. Java 异常类层次结构图

JAVA学习总结 - 图1

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类 Exception(异常)和 Error(错误)。Exception 能被程序本身处理(try-catch), Error 是无法处理的(只能尽量避免)。

  • Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 受检查异常(必须处理) 和 不受检查异常(可以不处理)。
  • ErrorError 属于程序无法处理的错误 ,我们没办法通过 catch 来进行捕获 。例如,Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

受检查异常
Java 代码在编译过程中,如果受检查异常没有被 catch/throw 处理的话,就没办法通过编译 。比如 IO 操作的代码。
除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有: IO 相关的异常、ClassNotFoundExceptionSQLException…。

不受检查异常
Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException 及其子类都统称为非受检查异常,例如:NullPointExecrptionNumberFormatException(字符串转换为数字)、ArrayIndexOutOfBoundsException(数组越界)、ClassCastException(类型转换错误)、ArithmeticException(算术错误)等。

3.2.3. try-catch-finally

  • try块: 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
  • catch块: 用于处理 try 捕获到的异常。
  • finally 块: 无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

在以下 3 种特殊情况下,finally 块不会被执行:

  1. tryfinally块中用了 System.exit(int)退出程序。但是,如果 System.exit(int) 在异常语句之后,finally 还是会被执行
  2. 程序所在的线程死亡。
  3. 关闭 CPU。


3.3.1. 简述线程、程序、进程的基本概念。以及他们之间关系是什么?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如 CPU 时间,内存空间,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。从另一角度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段。

3.3.2. 进程有哪些基本状态以及进程之间的转换?

通常进程有以下五种状态:

  1. 运行状态:正在处理机上运行
  2. 就绪状态:进程进入就绪队列,一旦得到处理机即可运行
  3. 阻塞状态:又称等待状态,进程正在等待某一事件(比如某资源变为可用)
  4. 创建状态:进程正在被创建,还未转入就绪状态。(进程的创建通常需要多个步骤)
  5. 结束状态:进程结束,正从系统中消失,可能是进程正常结束也可能是其他原因导致中断而退出。

进程状态的转换主要是前三种基本状态之间的转换:
image.png

3.3.3. 线程有哪些基本状态?线程是如何切换状态的?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态

JAVA学习总结 - 图3

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示

JAVA学习总结 - 图4

操作系统隐藏 Java 虚拟机(JVM)中的 READY 和 RUNNING 状态,它只能看到 RUNNABLE 状态,所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。

线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 cpu 时间片(timeslice)后就处于 RUNNING(运行) 状态。
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。

3.4.1. Java 中 IO 流分为几种?

  • 按照流的流向分,可以分为输入流和输出流;
  • 按照操作单元划分,可以划分为字节流和字符流;
  • 按照流的角色划分为节点流和处理流。

Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java I0 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

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

按操作方式分类结构图:

JAVA学习总结 - 图5
按操作对象分类结构图:

JAVA学习总结 - 图6

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

问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

阻塞和非阻塞

  • 阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
  • 非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。


3,4,2 别再用”==”比较浮点数了

处理浮点数的时候如果操作不当很容易造成精度丢失
那遇到浮点数的时候该如何正确的进行比较呢?
常用的方法有两种:
1、规定误差区间
虽然我们无法做到精确的比较,但是我们可以确定一个误差范围,只要小于这个误差范围,我们就可以它们是相等的。

  1. final double threshold = .00001;
  2. double d1 = .1 * 3;
  3. double d2 = .3;
  4. if (Math.abs(d1 - d2) < threshold) {
  5. System.out.println("d1 = d2");
  6. } else {
  7. System.out.println("d1 != d2");
  8. }

2、使用BigDecimal的compareTo()
BigDecimal是在java.math中的一个API,用来对超过16位有效位的数字进行精确运算的类。在涉及到交易等商业运算中建议使用BigDecimal.
当使用它进行运算时,传统的 +-*/ 是不能使用的,需要使用其对应的方法

  1. BigDecimal b1 = new BigDecimal("0.1");
  2. BigDecimal b2 = new BigDecimal("2");
  3. b1.add(b2); // b1 + b2
  4. b1.subtract(b2); // b1 - b2
  5. b1.multiply(b2); // b1 * b2
  6. b1.divide(b2); // b1 ÷ b2

另外,在使用DigDecimal创建对象的时候,需要注意的是
使用的参数不要使用构造参数为double的,要使用string的构造方法!!!
使用string的构造方法,是为了防止精度丢失。
在进行比较的时候,刻意使用Decimal的compareTo()方法或者 equals方法。
在使用a.compareTo(b)的时候,返回结果如下

  • a < b :返回-1
  • a = b :返回0
  • a > b :返回1

相比于equalscompareTo更严谨一点。

浅谈sleep、wait、yield、join的区别

  1. sleep

sleep 方法是属于 Thread 类中的,sleep 过程中线程不会释放锁,只会阻塞线程,让出cpu给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态,可中断,sleep 给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会

  1. wait

wait 方法是属于 Object 类中的,wait 过程中线程会释放对象锁,只有当其他线程调用 notify 才能唤醒此线程。wait 使用时必须先获取对象锁,即必须在 synchronized 修饰的代码块中使用,那么相应的 notify 方法同样必须在 synchronized 修饰的代码块中使用,如果没有在synchronized 修饰的代码块中使用时运行时会抛出IllegalMonitorStateException的异常

  1. yield

和 sleep 一样都是 Thread 类的方法,都是暂停当前正在执行的线程对象,不会释放资源锁,和 sleep 不同的是 yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。还有一点和 sleep 不同的是 yield 方法只能使同优先级或更高优先级的线程有执行的机会

  1. join

等待调用join方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。例如:主线程创建并启动了子线程,如果子线程中药进行大量耗时运算计算某个数据值,而主线程要取得这个数据值才能运行,这时就要用到 join 方法了

1. 说一下ArrayList和LinkedList区别

  1. 首先他们底层数据结构不一样, ArrayList底层是基于数组实现的, LinkedList底层是基于链表(双向)实现的
  2. 由于底层数据结构不一样, 所以他们的特点也是不一样的, 总的来说ArrayList更适合查找, 修改操作, 而LinkedList更适合插入, 删除操作, 但是这也不能一概而论, 因为如果只是把元素添加或删除列表尾部元素, ArrayList也很快, 但也可能会遇到数组需要扩容的清空, 所以, 需要看具体情况
  3. 另外ArrayList和LinkedList都实现了List接口, 但是LinkedList还额外实现了Deque接口, 所以LinkedList还可以当做队列使用
  4. LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

2. 说一下HashMap的put方法

hashMap的put方法大致流程是:

  1. 先根据key通过哈希算法和与运算得出要存储的数组下标
  2. 如果该数组下标元素为空, 则将key和value封装为Entry ( jdk1.7 ) 或Node ( jdk1.8 ) 对象, 存入该数组下标位置
  3. 如果数组下标位置不空, JDK1.7 和 1.8 处理流程略有区别, 区别如下:
    1. 如果是1.7 : 会先判断是否需要扩容, 如果需要扩容, 则先进行扩容, 如果不需要扩容, 则生成Entry对象, 使用头插法插入到当前位置的链表当中
    2. 如果是1.8 : 会先判断当前位置上的Node的类型, 可能是红黑树Node类型, 也可能是链表Node类型
      1. 如果是红黑树Node类型, 则将key和value封装为一个红黑树节点并添加到该红黑树中去, 在红黑树中如果存在该key(判断两个对象是否相等的方法参考3), 则只做更新
      2. 如果该位置是链表Node类型, 则将key和value封装为一个链表Node, 并通过尾插法插入到当前链表当中, 尾插法的过程中, 需要遍历链表, 如果链表中已经存在该key, 则只更新, 如果不存在,则插到链尾, 过程中记录链表长度, 如果插入后, 链表长度超过8, 则会将该链表转换成红黑树
      3. 在将key和value插入完成后, 再去判断是否需要扩容, 如果需要扩容, 则进行扩容

3. 说一下HashSet如何保证不重复?

Set接口代表无序的、元素不可重复的集合,当向HashSet中加入一个元素时,它需要判断集合中是否已经包含了这个元素,从而避免重复存储。由于这个判断十分的频繁,所以要讲求效率,绝不能采用遍历集合逐个元素进行比较的方式。
HashSet是通过获取对象的哈希码,以及调用对象的equals()方法来解决这个判断问题的。
HashSet首先会调用对象的hashCode()方法获取其哈希码,并通过哈希码确定该对象在集合中存放的位置。假设这个位置之前已经存在对象,则HashSet会调用equals()对该对象和存放位置上的链表上的对象逐一比较, 如果不等说明对象不重复,但是它们存储的位置发生了碰撞,此时HashSet将新对象链接到链尾 。

4. 说一说hashCode()和equals()的关系, 为什么要重写hashCode()和equals()?

Object类提供的equals()方法默认是用==来进行比较的,也就是说只有两个对象是同一个对象时,才能返回相等的结果。而实际的业务中,我们通常的需求是,若两个不同的对象它们的内容是相同的,就认为它们相等。鉴于这种情况,Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重写。由于hashCode()与equals()具有联动关系, 比如Set集合判重时会先比较hash值, 然后调equals()方法, 所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足如下约定:

  • 如果两个对象相等,则它们必须有相同的哈希码。
  • 如果两个对象有相同的哈希码,则它们未必相等。

5. 接口和抽象类有什么区别?

从使用方式上来说,二者有如下的区别:

  • 接口里只能包含抽象方法、静态方法( JDK 1.8 )、默认方法( JDK 1.8 )和私有方法( JDK 1.9 ),不能为普通方法提供方法实现;抽象类则完全可以包含普通方法。
  • 接口里只能定义静态常量,不能定义普通成员变量;抽象类里则既可以定义普通成员变量,也可以定义静态常量。
  • 接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。
  • 接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
  • 一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接口可以弥补Java单继承的不足。

6. 请介绍一下Java的异常接口

Throwable是异常的顶层父类,代表所有的非正常情况。它有两个直接子类,分别是Error、Exception。
Error是错误,一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败等,这种错误无法恢复或不可能捕获,将导致应用程序中断。
Exception是异常,它被分为两大类,分别是Checked异常和Runtime异常。所有的RuntimeException类及其子类的实例被称为Runtime异常;不是RuntimeException类及其子类的异常实例则被称为Checked异常。Java认为Checked异常都是可以被处理(修复)的异常,所以Java程序必须显式处理Checked异常。如果程序没有处理Checked异常,该程序在编译时就会发生错误,无法通过编译。Runtime异常则更加灵活,Runtime异常无须显式声明抛出,如果程序需要捕获Runtime异常,也可以使用try…catch块来实现。

7. final的特点

final关键字可以修饰类、方法、变量,以下是final修饰这3种目标时表现出的特征:

  • final类:final关键字修饰的类不可以被继承。
  • final方法:final关键字修饰的方法不可以被重写。
  • final变量:final关键字修饰的变量,一旦获得了初始值,就不可以被修改。

8. 说一说Java的四种引用方式

9. Java中接口的基类是不是Object?

答: 不是, 接口没有基类概念, 接口继承时是组合的关系, 因此接口可以多继承(多组合). 但是接口隐式地实现了Object类的所有公共方法, 因此接口编译成class字节码时, 可以看到Object被放入了常量池中, 是因为接口中有个Object的隐式引用.

10. 接口和抽象类的区别?

  • 接口中只能包含抽象方法、静态方法、默认方法,不能为普通方法提供方法实现;抽象类则完全可以包含普通方法
  • 接口中只能定义静态常量,不能定义普通成员变量;抽象类里则可以定义普通成员变量
  • 接口没有构造器,抽象类可以有构造器,但抽象类的构造器不适用于创建对象,而是让子类调用构造器来完成抽象类的初始化
  • 接口中不能包含初始化块,抽象类可以包含初始化块
  • 在使用上,接口可以多继承,抽象类只能单继承
  • 从思想理念上看,接口体现的是一种规范,是多个模块间的耦合标准。抽象类体现的是一种模板式设计,可以当作是系统实现过程中的中间产品,这个中间产品已经实现了系统的部分功能,但依然不能当作最终产品,还需进一步完善。

11. 请介绍LinkedHashMap的底层原理, 与HashMap有何区别?

LinkedHashMap继承于HashMap,它在HashMap的基础上,通过维护一条双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题。在实现上,LinkedHashMap很多方法直接继承自HashMap,仅为维护双向链表重写了部分方法。
如下图,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新的键值对节点插入时,新节点最终会接在tail引用指向的节点后面。而tail引用则会移动到新的节点上,这样一个双向链表就建立起来了。
JAVA学习总结 - 图7

  • LinkedHashMap使用双向链表来维护key-value对的顺序(其实只需要考虑key的顺序),该链表负责维护Map的迭代顺序,迭代顺序与key-value对的插入顺序保持一致。
  • LinkedHashMap可以避免对HashMap、Hashtable里的key-value对进行排序(只要插入key-value对时保持顺序即可),同时又可避免使用TreeMap所增加的成本。
  • LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能。但因为它以链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。

12. 谈谈CopyOnWriteArrayList的原理

CopyOnWriteArrayList是Java并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。正如其名字一样,在写操作时会复制一份新的List,在新的List上完成写操作,然后再将原引用指向新的List。这样就保证了写操作的线程安全。
CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高。而写操作的时候,则首先将容器复制一份,然后在新的副本上执行写操作,这个时候写操作是上锁的。结束之后再将原容器的引用指向新容器。注意,在上锁执行写操作的过程中,如果有需要读操作,会作用在原容器上。因此上锁的写操作不会影响到并发访问的读操作。

  • 优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。在遍历传统的List时,若中途有别的线程对其进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其”读写分离”的思想,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了。
  • 缺点:一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC。二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。

13. 说一说HashSet的底层结构

HashSet是基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。它封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。

14. 说说NIO的实现原理

Java的NIO主要由三个核心部分组成:Channel、Buffer、Selector。
基本上,所有的IO在NIO中都从一个Channel开始,数据可以从Channel读到Buffer中,也可以从Buffer写到Channel中。Channel有好几种类型,其中比较常用的有FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel等,这些通道涵盖了UDP和TCP网络IO以及文件IO。
Buffer本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。Java NIO里关键的Buffer实现有CharBuffer、ByteBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。这些Buffer覆盖了你能通过IO发送的基本数据类型,即byte、short、int、long、float、double、char。
Buffer对象包含三个重要的属性,分别是capacity、position、limit,其中position和limit的含义取决于Buffer处在读模式还是写模式。但不管Buffer处在什么模式,capacity的含义总是一样的。

  • capacity:作为一个内存块,Buffer有个固定的最大值,就是capacity。Buffer只能写capacity个数据,一旦Buffer满了,需要将其清空才能继续写数据往里写数据。
  • position:当写数据到Buffer中时,position表示当前的位置。初始的position值为0。当一个数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity–1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
  • limit:在写模式下,Buffer的limit表示最多能往Buffer里写多少数据,此时limit等于capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据,此时limit会被设置成写模式下的position值。

Selector允许单线程处理多个 Channel,如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件例如有新连接进来,数据接收等。
这是在一个单线程中使用一个Selector处理3个Channel的图示:
JAVA学习总结 - 图8

15. 介绍一下Java的序列化与反序列化

序列化机制可以将对象转换成字节序列,这些字节序列可以保存在磁盘上,也可以在网络中传输,并允许程序将这些字节序列再次恢复成原来的对象。其中,对象的序列化(Serialize),是指将一个Java对象写入IO流中,对象的反序列化(Deserialize),则是指从IO流中恢复该Java对象。
若对象要支持序列化机制,则它的类需要实现Serializable接口,该接口是一个标记接口,它没有提供任何方法,只是标明该类是可以序列化的,Java的很多类已经实现了Serializable接口,如包装类、String、Date等。
若要实现序列化,则需要使用对象流ObjectInputStream和ObjectOutputStream。其中,在序列化时需要调用ObjectOutputStream对象的writeObject()方法,以输出对象序列。在反序列化时需要调用ObjectInputStream对象的readObject()方法,将对象序列恢复为对象。
serialVersionUID代表序列化的版本,通过定义类的序列化版本,在反序列化时,只要对象中所存的版本和当前类的版本一致,就允许做恢复数据的操作,否则将会抛出序列化版本不一致的错误。
如果不定义序列化版本,在反序列化时可能出现冲突的情况,例如:

  1. 创建该类的实例,并将这个实例序列化,保存在磁盘上;
  2. 升级这个类,例如增加、删除、修改这个类的成员变量;
  3. 反序列化该类的实例,即从磁盘上恢复修改之前保存的数据。

在第3步恢复数据的时候,当前的类已经和序列化的数据的格式产生了冲突,可能会发生各种意想不到的问题。增加了序列化版本之后,在这种情况下则可以抛出异常,以提示这种矛盾的存在,提高数据的安全性。

16. 创建线程有哪几种方式?

创建线程有三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。

  1. 通过继承Thread类来创建并启动线程的步骤如下:
    1. 定义Thread类的子类,并重写该类的run()方法,该run()方法将作为线程执行体。
    2. 创建Thread子类的实例,即创建了线程对象。
    3. 调用线程对象的start()方法来启动该线程。
  2. 通过实现Runnable接口来创建并启动线程的步骤如下:
    1. 定义Runnable接口的实现类,并实现该接口的run()方法,该run()方法将作为线程执行体。
    2. 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线程对象。
    3. 调用线程对象的start()方法来启动该线程。
  3. 通过实现Callable接口来创建并启动线程的步骤如下:
    1. 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。然后再创建Callable实现类的实例。
    2. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
    3. 使用FutureTask对象作为Thread对象的target创建并启动新线程。
    4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

17. 说一说synchronized的底层实现原理

synchronized作用在代码块时,它的底层是通过monitorenter、monitorexit指令来实现的。

18. 了不了解volatile?volatile的实现机制?

答:
volatile的基本特性:

  • 可见性:对一个volatile变量的读,总是能看到对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读或者写都具有原子性的

volatile的内存语义:

  • 写内存语义:当写一个volatile变量时, JMM会把该线程本地内存中的共享变量的值刷新到主内存中
  • 读内存语义:当读一个volatile变量时, JMM会把该线程本地内存置为无效. 使其从主内存中读取共享变量

volatile的实现机制:

  • 为了实现volatile的内存语义, 编译器在生成字节码时, 会在指令序列中插入内存屏障来禁止指定类型的处理器重排序.

volatile与锁的对比:

  • volatile仅仅保证对单个volatile变量的读/写具有原子性, 而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性. 在功能上锁比volatile更加强大, 在可伸缩性和执行性能上volatile更有优势.
  • synchronize的实现机制是采用CAS+Mark Word来实现, 存在锁升级的情况.
  • Lock的实现机制是采用CAS+volatile来实现,存在锁降级的情况, 核心是AQS.

19. 什么是CAS?

CAS全称是Compare And Swap

20. 什么是ThreadLocal? 它有哪些应用?

ThreadLocal可以保证每个线程有自己的独占空间, 当前线程放入ThreadLocal中的对象, 其他线程无法读到, 只有当前线程自己可读取.
应用: 我们知道Spring的声明式事务@Transaction注解用于实现一个事务, 现在假如有一个事务方法m(), 方法里面调了另外两个进行数据库操作的方法m1()和m2()
那么, 如果在执行m1()方法的时候, 去数据库连接池中取了一个连接对象进行数据库操作, 而执行m2()方法的时候又去数据库连接池中取了另一个连接对象进行数据库操作, 那么这两个连接进行的数据操作就没办法保证事务的原子性了. 那么怎么样才能保证这个原子性呢? 我们知道这两个方法m1()和m2()一定是在同一个线程里执行的, 因此就可以把取到的数据库连接放在这个线程的ThreadLocal当中, 那么当调用m1()和m2()的时候, 让他们先去ThreadLocal中去拿连接对象就可以保证他们拿到的连接对象是同一个了.

ThreadLocal的原理:
实际上, 每次new Thread()的时候, 这个先创建的线程内有一个ThreadLocalMap, 每次调用ThreadLocal的set(T value)方法时, 会将当前的ThreadLocal对象作为键key, 要设置的T value作为值存入当前线程的ThreadLocalMap中. 因此其他线程是取不到这个值的.