Java容器、并发编程、JVM

JAVA基础知识补充内容


集合

1. Java 集合概览

Java 集合, 也叫作容器,主要是由两大接口
Collection 接口:存放单一元素;(三个子接口:ListSetQueue。)
Map 接口:存放键值对
集合

2. List, Set, Queue, Map 四者的区别?

List:存储的元素是有序的、可重复的。
Set:存储的元素是无序的、不可重复的。
Queue:按特定的排队规则来确定先后顺序(链式存储),存储的元素是有序的可重复的。
Map:使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key无序的、不可重复的,value无序的、可重复的,每个键最多映射到一个值。

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

先来看一下 Collection 接口下面的集合。

  1. List
    ArraylistObject[] 数组
    VectorObject[] 数组
    LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

  2. Set
    HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
    LinkedHashSet: LinkedHashSetHashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
    TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

  3. Queue
    PriorityQueueObject[] 数组来实现二叉堆
    ArrayQueueObject[] 数组 + 双指针

  4. Map
    HashMap:由数组 + 双链表组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)JDK1.8 以后当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间
    LinkedHashMap:由数组 + 双链表/红黑树组成,继承自 HashMap,底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。区别在于增加了一条双向链表(链操作),使得上面的结构可以保持键值对的插入顺序。
    Hashtable数组(主体)+ 单链表(解决哈希冲突)组成。
    TreeMap:红黑树(自平衡的排序二叉树)

4. 如何选用集合?

  1. 根据键值获取到元素值时:Map
    TreeMap:需要排序时
    HashMap:不需要排序时
    ConcurrentHashMap:线程安全
  2. 只需要存放元素值时:Collection
    TreeSetHashSet:保证元素唯一 Set
    ArrayListLinkedList:不需要保证唯一 List

5. 为什么要使用集合?

保存一组类型相同的数据时,需用一个容器保存,这个容器就是数组,但是实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。
数组的缺点:① 一旦声明之后,长度不可变;② 声明数组时的数据类型也决定了该数组存储的数据的类型;③ 数组存储的数据是有序的、可重复的,特点单一
集合提高了数据存储的灵活性,Java 集合不仅可以用来 ① 存储不同类型不同数量的对象,还可以保 ② 存具有映射关系的数据。

Collection 子接口之 List

1. Arraylist 和 Vector 的区别?

底层使用 Object[ ] 数组 存储
ArrayList (主要实现类):适用于频繁查找工作,线程不安全
Vector线程安全

2. Arraylist 与 LinkedList 区别?

  1. 是否保证线程安全:都是不同步(不保证线程安全)
  2. 底层数据结构Arraylist 底层是 Object[ ]LinkedList 底层是 Object[ ]双向链表
  3. 插入和删除是否受元素位置的影响
    Arraylist:采用数组存储,顺序存储。元素追加到列表末尾O(1),插入或删除O(n-i)
    LinkedList:采用链表存储,链式存储。在头尾插入或者删除元素O(1),指定位置O(n)。
  4. 快速随机访问:顺序存储支持(索引),链式不支持(遍历)。
  5. 内存空间占用:Arraylist 列表的结尾会预留一定的容量空间(数组,磁盘碎片)。LinkedList 占用更大(存放前驱后继及数据)

3. ArrayList 的扩容机制

  • 以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
  • 添加第2、3···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。
  • 直到添加第 11 个元素minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。

Collection 子接口之 Set

1. comparable 和 Comparator 的区别?

comparable 接口(java.lang):compareTo(Object obj) 方法排序
comparator 接口(java.util):compare(Object obj1, Object obj2) 方法排序
一般我们需要对一个集合使用自定义排序时,我们就要重写 compareTo() 方法或 compare() 方法

2. 无序性和不可重复性的含义是什么?

  • 无序性:指存储的数据在底层数组中并非按照数组 索引 的顺序添加 ,而是根据数据的 哈希值 决定的。
  • 不可重复性:指添加的元素按照 equals() 判断时 ,返回 false(需要同时重写 equals() 方法和 HashCode()方法)

3. HashSet、LinkedHashSet 和 TreeSet 三者的异同

:① 都是 Set 接口的实现类,② 都能保证元素唯一,并且③ 都不是线程安全的。
:① 底层数据结构不同。

  • HashSet哈希表(基于 HashMap 实现)
  • LinkedHashSet哈希表双向链表(FIFO)。
  • TreeSet哈希表红黑树。元素是有序的,排序的方式有自然排序和定制排序。
    ② 底层数据结构不同又导致这三者的应用场景不同。
  • HashSet不需要保证元素插入和取出顺序场景
  • LinkedHashSet保证元素的插入和取出顺序满足FIFO。
  • TreeSet:支持对元素自定义排序规则。

Collection 子接口之 Queue

1. QueueDeque 的区别

Queue:单端队列,单端插入或删除元素,先进先出(FIFO)
Deque:双端队列,双端插入或删除元素。

2. ArrayDeque 与 LinkedList 的区别

:都实现了 Deque 接口,两者都具有队列的功能

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
  • ArrayDeque 来实现队列要比 LinkedList 性能更好。还能实现栈。

3. PriorityQueue

JDK1.5 中引入, 其与 Queue 的区别在于元素出队顺序是与优先级相关,即总是优先级最高的元素先出队。

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据。
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

Map 接口

1. HashMap 和 Hashtable 的区别

  1. 线程是否安全HashMap非线程安全的,Hashtable线程安全的(内部的方法基本都经过synchronized 修饰)。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率HashMap线程非安全)要比 Hashtable (基本淘汰)效率高一点。
  3. 对 Null key 和 Null value 的支持HashMap 可以存储 nullkey (1个)和 value (多个)
  4. 初始容量大小和每次扩充容量大小的不同:HashMap 会将其扩充为 2 的幂次方大小,Hashtable 扩充 2n+1。
  5. 底层数据结构HashMap 在解决哈希冲突时当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。

2. HashMap 和 HashSet 区别

HashSet 底层基于 HashMap 实现。

HashMapHashSet
实现 Map 接口实现 Set 接口
存储键值对仅存储对象
调用 put()map 中添加元素调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcodeHashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

3. HashMap 和 TreeMap 区别

TreeMapHashMap 都继承自 AbstractMap ,但是需要注意的是 TreeMap 它还实现了 NavigableMap 接口和SortedMap 接口。
NavigableMap 接口:让 TreeMap 有了对集合内元素的搜索能力
SortedMap 接口:让 TreeMap 有了对集合中的元素根据键排序能力

4. HashSet 如何检查重复

对象加入HashSet 时,HashSet 会先计算对象的 hashcode来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较.

  • 如果没有相符的 hashcodeHashSet 会假设对象没有重复出现。
  • 如果发现有相同 hashcode 值的对象,这时会调用equals() 方法来检查 hashcode 相等的对象是否真的相同。
  • 如果两者相同,HashSet 就不会让加入操作成功。

5. HashMap 的底层实现

JDK1.8 之前 HashMap 底层:数组和链表(链表散列)(哈希冲突用拉链法。)
JDK1.8 之后 HashMap 底层:数组和链表/红黑树(当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。)

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

存放的位置(对应的数组下标,存放松散防冲突)计算方法是: hash & (n - 1)
hash%length==hash&(length-1) 前提是 length = 2^n。二进制位操作效率更好。

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

并发下的 Rehash 会造成元素之间会形成一个循环链表。
多线程下使用 HashMap 还是会存在其他问题比如数据丢失。
(并发环境下推荐使用 ConcurrentHashMap )。

8. ConcurrentHashMap 和 Hashtable 的区别

主要体现在:实现线程安全的方式

  • 底层数据结构:JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,
    JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
  • 实现线程安全的方式
    ① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作
    Hashtable(同一把锁):使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

9. Collections 工具类

Collections 工具类常用方法:

  1. 排序
void reverse(List list)//反转
void shuffle(List list)//随机排序
void sort(List list)//按自然排序的升序排序
void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑
void swap(List list, int i , int j)//交换两个索引位置的元素
void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面
  1. 查找,替换操作
int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的
int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll)
int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c)
void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素
int frequency(Collection c, Object o)//统计元素出现次数
int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target)
boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素
  1. 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合)
    Collections 提供了多个 synchronizedXxx() 方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。

并发编程

1.什么是进程和线程?

  • 进程:程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。
    系统运行一个程序即是一个进程从创建,运行到消亡的过程。
    main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
  • 线程:比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。
    一个 Java 程序的运行是 main 线程和多个其他线程同时运行。

2. 图解进程和线程的关系

在这里插入图片描述
一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小(轻量级进程),但不利于资源的管理和保护;而进程正相反。

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

程序计数器私有主要是为了:线程切换后能恢复到正确的执行位置

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

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

保证线程中的局部变量不被别的线程访问到

  • 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

5. 一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源.

  • 是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存).
  • 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

6. 并发与并行的区别?

  • 并发:两个及两个以上的作业在 单位时间段 内执行。
  • 并行:两个及两个以上的作业在 单位时间 执行。

7. 为什么要使用多线程呢?

  • 计算机底层:线程是轻量级进程,线程间的切换和调度的成本远远小于进程。
  • 互联网发展(千万级的高并发量):提高系统整体的并发能力以及性能
  • 单核时代:使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率
  • 多核时代:提高进程利用多核 CPU 的能力。

8. 使用多线程可能带来什么问题?

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

9. 说说线程的生命周期和状态?

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

10. 什么是上下文切换?

上下文:线程在执行过程中会有自己的运行条件和状态(程序计数器,栈信息等)。
当出现如下情况的时候,线程会从占用 CPU 状态中退出:

  1. 主动让出 CPU,比如调用了 sleep(), wait() 等。
  2. 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
  3. 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  4. 被终止或结束运行
    上下文切换:前三种都会发生线程切换需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。(频繁切换就会造成整体效率低下)

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

线程死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。(线程被无限期地阻塞,因此程序不可能正常终止)
在这里插入图片描述
死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。(一次性申请所有的资源)
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。(占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源)
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。(靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。)

避免死锁:在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

12. 说说 sleep() 方法和 wait() 方法区别和共同点?

  • :两者都可以暂停线程的执行。
  1. sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
  2. wait() 通常被用于线程间交互/通信sleep() 通常被用于暂停执行
  3. wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

13. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

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

Java 并发常见知识点&面试题总结(进阶篇)

1. synchronized 关键字

解决的是:多个线程之间访问资源的同步性。保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
Java 早期版本中,synchronized 属于 重量级锁,效率低下。
监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,需要相对比较长的时间,时间成本相对较高。
JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销

2. 说说自己是怎么使用 synchronized 关键字

  1. synchronized 关键字加到 static 静态方法 synchronized(class) 代码块上都是是给 Class上锁。
  2. synchronized 关键字加到实例方法上是给对象实例上锁。(差别)
  3. 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

3. 构造方法可以使用 synchronized 关键字修饰么?

不能。构造方法本身就属于线程安全的,不存在同步的构造方法一说。

4. 讲一下 synchronized 关键字的底层原理

  1. synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
  2. synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权(两者本质)

  • 在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可被获取,获取后将锁计数器b 也就是加 1。
  • 对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
  • 如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

5. JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可升级不可降级,这种策略是为了提高获得锁和释放锁的效率

6. synchronized(同步锁)和 ReentrantLock(重入锁)的区别

  1. 两者都是可重入锁
  2. synchronized 依赖于 JVMReentrantLock 依赖于 API
  3. ReentrantLocksynchronized 增加了一些高级功能:
  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件):synchronized 关键字与 wait()notify()/notifyAll() 方法相结合可以实现等待/通知机制ReentrantLock 类当然也可以实现,但是需要借助于Condition 接口与 newCondition() 方法。

volatile(不同步的) 关键字

1. CPU 缓存模型

CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
我们甚至可以把内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
CPU Cache 的工作方式:
先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory(主存) 中。但是,这样存在 内存缓存不一致性的问题(制定缓存一致协议或者其他手段来解决)!

2. 讲一下 JMM(Java 内存模型)(java memory model)

Java 内存模型抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中
Java 内存模型主要目的:屏蔽系统和硬件的差异,避免一套代码在不同的平台下产生的效果不一致。

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
  • 本地内存:每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本

解决:把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile 关键字:① 防止 JVM 的指令重排 ,② 保证变量的可见性
在这里插入图片描述
在这里插入图片描述

3. 并发编程的三个重要特性

  1. 原子性:一次操作或者多次操作,要么所有的操作全部执行并且不会受到任何因素的干扰而中断,要么都不执行synchronized 可以保证代码片段的原子性。
  2. 可见性:当一个线程对共享变量进行了修改,那么另外的其它线程都是立即可以看到修改后的最新值volatile 关键字可以保证共享变量的可见性。
  3. 有序性代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

4. synchronized 关键字和 volatile 关键字的区别

synchronized (同步的) 关键字和 volatile (不同步的) 关键字是两个互补的存在。

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

ThreadLocal

1. ThreadLocal 简介

实现每一个线程都有自己的专属本地变量,让每个线程绑定自己的值。
如果你创建了一个ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal 变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

2. ThreadLocal 原理

最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

每个Thread中都具备一个 ThreadLocalMap ,而 ThreadLocalMap可以存储以 ThreadLocal 为 keyObject 对象为 value 的键值对

3. ThreadLocal 内存泄露问题

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

线程池

1. 为什么要用线程池?

线程池:提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
池化技术:减少每次获取资源的消耗,提高对资源的利用率。
好处

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

2. 实现 Runnable 接口和 Callable 接口的区别

  • Callable 仅在 Java 1.5 中引入,目的就是为了来处理 Runnable (1.0)不支持的用例
  • Runnable 接口 不会返回结果或抛出检查异常,但是 Callable 接口 可以。
  • 工具类 Executors :将 Runnable 对象转换成 Callable 对象。
    Executors.callable(Runnable task)Executors.callable(Runnable task, Object result)

3. 执行 execute()方法和 submit()方法的区别是什么呢?

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

4. 如何创建线程池

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

5. 线程池原理分析

线程池首先会先执行 5 个(corePoolSize核心线程数)任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。
线程池原理

Atomic 原子类

1. 介绍一下 Atomic 原子类

原子性:要么都执行(且不被其他线程干扰)要么都不执行。
原子类:具有原子性的类。并发包原子类都存放在 java.util.concurrent.atomic

2. JUC 包中的原子类是哪 4 类?

基本类型AtomicIntegerAtomicLongAtomicBoolean
数组类型AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray
引用类型AtomicReferenceAtomicStampedReferenceAtomicMarkableReference
对象的属性修改类型AtomicIntegerFieldUpdaterAtomicLongFieldUpdaterAtomicReferenceFieldUpdater

3. 讲讲 AtomicInteger 的使用

AtomicInteger 类常用方法(使用 AtomicInteger 之后,不需要对该方法加锁,也可以实现线程安全。):

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

4. 简单介绍一下 AtomicInteger 类的原理

AtomicInteger 类主要利用 CAS (比较 交换)+ volatile (可见性)和 native 方法来保证原子操作,从而避免 synchronized 的高开销(比较),执行效率大为提升。
CAS:拿期望的值和原本的一个值作比较,如果相同则更新成新的值。(UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。)
value 是一个 volatile 变量,在内存中可见。
JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

AQS(AbstractQueuedSynchronizer) 抽象队列同步器

1. AQS 介绍

java.util.concurrent.locks 包下面
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出大量应用广泛的同步器,比如我们提到的 ReentrantLockSemaphoreReentrantReadWriteLockSynchronousQueueFutureTask

2. AQS 原理分析

AQS 核心思想是:

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

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

3. AQS 定义两种资源共享方式

  1. Exclusive(独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:
    公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  2. Share(共享):多个线程可同时执行,如 CountDownLatchSemaphoreCyclicBarrierReadWriteLock 我们都会在后面讲到。(ReentrantReadWriteLock:读写锁允许多个线程同时对某一资源进行读)

4. 用过 CountDownLatch 么?什么场景下用的?

CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕

JVM

Java 内存区域详解

虚拟机自动内存管理机制下,不再需要像 C/C++程序为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏内存溢出问题。

1. 运行时数据区域

数据区域
线程私有的:① 程序计数器;② 虚拟机栈;③ 本地方法栈。
线程共享的:① 堆;② 方法区;③直接内存 (非运行时数据区的一部分)

  1. 程序计数器
  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
    程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建/结束而创建/死亡
  1. Java 虚拟机栈
  • 生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。(堆内存栈内存
  • 栈帧组成,而每个栈帧中都拥有:局部变量表操作数栈动态链接方法出口信息(java方法的)。
  • 两种错误StackOverFlowError 栈深错误 OutOfMemoryError 内存不足。
  • Java 栈可以类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出(释放内存空间)。
  1. 本地方法栈
    虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,
    本地方法栈则为虚拟机使用到的 Native 方法服务。
    本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。调用结束后,也都会有一个栈帧被弹出(释放内存空间)。


  2. 存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
    Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆。
    垃圾回收收集器基本都采用 分代垃圾收集算法
    Java堆可细分:新生代(Eden, Survivor)、老年代(Old) 和 永久代(1.7前)/元空间(1.8后)。
    JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存
    对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。
    Hostspot VM堆结构

  3. 方法区
    存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  4. 运行时常量池(方法区的一部分)
    Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)

  5. 直接内存

3. Java 对象的创建过程(能默写)

类加载检查分配内存初始化零值设置对象头执行 init 方法

  1. 类加载检查
    虚拟机遇到 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
    类的生命周期
  2. 分配内存
    为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
    内存分配的两种方式:
  • 指针碰撞
    适用场合 :堆内存规整(即没有内存碎片)的情况下。
    原理 :过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表
    适用场合 :堆内存不规整的情况下。
    原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    使用该分配方式的 GC 收集器:CMS
  1. 初始化零值
    分配到的内存空间都初始化为零值(不包括对象头),保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。程序访问到数据类型所对应的零值。
  2. 设置对象头
    信息存放(对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息)在对象头中。
  3. 执行 init 方法
    执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿初始化

4. 对象的内存布局

3 块区域:对象头实例数据对齐填充
对象头:① 对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等) ② 类型指针。
实例数据:对象真正存储的有效信息。
对齐填充:仅占位作用。

5. 对象的访问定位

Java 程序通过栈上的 reference 数据来操作堆上的具体对象。
对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

  • 句柄:Java 堆中划分出一块内存作为句柄池reference (引用)中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
    reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改)
  • 直接指针reference 中存储的直接就是对象地址
    (速度快,它节省了一次指针定位的时间开销)
    句柄
    直接指针

JVM 垃圾回收详解

JVM垃圾回收

1. JVM 内存分配与回收

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。
最核心的功能是 内存中对象的分配与回收。
收集器基本都采用分代垃圾收集算法,Java 堆可细分为:新生代(Eden、From Survivor、To Survivor)和老年代。进一步划分的目的是更好地回收和分配内存。
JAVA堆
对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为大于 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX: MaxTenuringThreshold 来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过 -XX:+PrintTenuringDistribution 来打印出当次 GC 后的 Threshold
(Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值)
经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,“From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,在这个过程中,有可能当次 Minor GC 后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。

  1. 对象优先在 eden 区分配
  2. 大对象直接进入老年代
  3. 长期存活的对象将进入老年代
  4. 动态对象年龄判定
  5. 主要进行 gc 的区域
  • 部分收集 (Partial GC):
    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
      老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
      混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。
  1. 空间分配担保
    (老年代最大可用的连续空间是否大于新生代所有对象总空间,否则再检查是否大于历次晋升到老年代对象的平均大小)

2. 对象已经死亡?

堆垃圾回收前的第一步就是要判断哪些对象已经死亡。(即不能再被任何途径使用的对象)。

  1. 引用计数法
    给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1
    引用失效,计数器就减 1
    任何时候计数器为 0 的对象就是不可能再被使用的。
    (实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。)
  2. 可达性分析算法
    通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链
    当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收
    引用链

3. 哪些对象可以作为 GC Roots 呢?(栈和方法区的对象,同步锁对象)

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

4. 对象可以被回收,就代表一定会被回收吗?

真正宣告一个对象死亡,至少要经历两次标记过程。
可达性分析法中不可达的对象(死缓)被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

5. 再谈引用

  • 强引用:绝不回收
  • 软引用:内存不足才回收,否则不回收。可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
  • 弱引用:更短暂的生命周期,一旦发现就回收(区别)。也可以联合引用队列回收。
  • 虚引用:任何时候都可能被垃圾回收。必须联合引用队列回收。

软引用最多,软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全防止内存溢出(OutOfMemory)等问题的产生。

6. 如何判断一个常量是废弃常量?

运行时常量池主要回收的是废弃的常量
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。
(JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace))

7. 如何判断一个类是无用的类?

同时满足下面 3 个条件才能算是 “无用的类” :

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收。

8. 垃圾收集算法

1. 标记-清除算法

标记清楚

2. 标记-复制算法

复制

3. 标记-整理算法

整理

4. 分代收集算法

新生代中,每次收集都会有大量对象死去,所以可以选择标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

9. 垃圾收集器

gc器
到jdk8为止,默认的垃圾收集器是Parallel ScavengeParallel Old
从jdk9开始,G1收集器成为默认的垃圾收集器 目前来看,G1回收器停顿时间最短而且没有明显缺点,非常适合Web应用。在jdk8中测试Web应用,堆内存6G,新生代4.5G的情况下,Parallel Scavenge 回收新生代停顿长达1.5秒。G1回收器回收同样大小的新生代只停顿0.2秒。


参考

本文档为个人方便自己熟记而整理,来自javaguide。
javaguide是个优秀的计算机知识整理:https://javaguide.cn/
波妞

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值