巨人的肩膀JAVA面试总结

Posted 生命是有光的

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了巨人的肩膀JAVA面试总结相关的知识,希望对你有一定的参考价值。

1、💪

目录

1、说说List, Set, Queue, Map 四者的区别

Collection是一个接口,Collections是一个工具类,Map不是Collection的子接口

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。可直接根据元素的索引来访问
  • Set(注重独一无二的性质): 存储的元素是无序的、不可重复的,只能根据元素本身来访问
  • Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的
  • Map(用 key 来搜索的专家): 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

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

1.1、List

  • ArrayList: 基于动态数组实现,支持随机访问。
  • Vector:和 ArrayList 类似,但它是线程安全的
  • LinkedList: 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。

1.2、Set

  • HashSet(无序,唯一): 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的

  • LinkedHashSet: LinkedHashSetHashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的,使用双向链表维护元素的插入顺序

  • TreeSet(有序,唯一): 基于红黑树实现,支持有序性操作,例如:根据一个范围查找元素的操作。但是查找效率不如 HashSet

1.3、Map

  • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMapLinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构。即由数组和链表或红黑树组成
  • Hashtable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。
  • TreeMap: 红黑树

2、如何选用集合

需要根据键值获取到元素值时就选用 Map 接口下的集合

  • 需要排序时选择 TreeMap
  • 不需要排序时就选择 HashMap
  • 需要保证线程安全就选用 ConcurrentHashMap

当我们只需要存放元素值时,就选择实现Collection 接口的集合:

  • 需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSetHashSet
  • 不需要保证元素唯一就选择实现 List 接口的比如 ArrayListLinkedList

4、线程安全的集合有哪些?线程不安全的呢?

线程安全的:

  • Hashtable:比HashMap多了个线程安全。
  • ConcurrentHashMap:是一种高效但是线程安全的集合。
  • Vector:比Arraylist多了个同步化机制。
  • Stack:栈,也是线程安全的,继承于Vector。

线性不安全的:

  • HashMap
  • Arraylist
  • LinkedList
  • HashSet
  • TreeSet
  • TreeMap

3、为什么需要使用集合

当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端, 因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。

数组的缺点是一旦声明之后,长度就不可变了;同时,声明数组时的数据类型也决定了该数组存储的数据的类型;而且,数组存储的数据是有序的、可重复的,特点单一。 但是集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据

4、comparable和Comparator的区别

  • comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序。

  • 一个类实现了 Comparable 接口,意味着该类的对象可以直接进行比较(排序),但比较(排序)的方式只有一种,很单一;一个类如果想要保持原样,又需要进行不同方式的比较(排序),就可以定制比较器(实现 Comparator 接口)
  • Comparable 更多的像一个内部比较器,而 Comparator 更多的像一个外部比较器(体现了一种策略模式,耦合性较低),如果对象的排序需要基于自然顺序,请选择 Comparable,如果需要按照对象的不同属性进行排序,请选择 Comparator

5、无序性和不可重复性的含义

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

6、比较HashSet、LinkedHashSet和TreeSet的异同

  • HashSetLinkedHashSetTreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
  • HashSetLinkedHashSetTreeSet 的主要区别在于底层数据结构不同。
    • HashSet 的底层数据结构是哈希表(基于 HashMap 实现)
    • LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。
    • TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序
  • 底层数据结构不同又导致这三者的应用场景不同
    • HashSet 用于不需要保证元素插入和取出顺序的场景
    • LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景
    • TreeSet 用于支持对元素自定义排序规则的场景

7、HashMap和Hashtable的区别

  1. HashTable 继承 Dictionary 类,而 HashMap 是继承 AbstractMap。
  • 线程是否安全:HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!
  • 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它
  • 对 Null key 和 Null value 的支持:HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值

HashMap的 size 为什么必须是 2 的整数次方?

这样做总是能够保证 HashMap 的底层数组长度为 2 的 n 次方,这样在计算机中转化二进制和进行与操作可以增加速度,这是 HashMap 在速度上的一个优化

HashMap 的 get 方法能否判断某个元素是否在 map 中?

HashMap 的 get 函数的返回值不能判断一个 key 是否包含在 map 中,因为 get 返回 null 有可能是不包含该 key,也有可能该 key 对应的 value 为 null。因为 HashMap 中允许 key 为 null,也允许 value 为 null。

LinkedHashMap的实现原理

LinkedHashMap 是基于 HashMap 实现的,但是多了header、before、after三个属性,有了这三个属性就能组成一个双向链表,来实现按插入顺序或访问顺序排序,其迭代顺序默认为插入顺序。

8、HashSet和HashMap的区别

HashMapHashSet
实现了Map接口实现Set接口
存储键值对仅存储对象
调用 put() 向 map 中添加元素调用 add() 方法向 set 中添加元素
HashMap使用键key来计算 HashcodeHashSet使用成员对象来计算 hashcode,再用 equals() 方法来判断对象的相等性
HashMap相对于HashSet较快,因为它是使用唯一的键获取对象

HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存

9、HashMap和TreeMap的区别

TreeMapHashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。

TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力

为什么HashMap中String、Integer这样的包装类适合作为key?

String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

  • 都是final类型,即不可变性,保证key的不可更改性,不会存在同一对象获取hash值不同的情况
  • 内部已重写了equals()hashCode()等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况

10、HashSet如何检查重复

当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

HashSet的实现原理?

HashSet 的实现是依赖于 HashMap 的,HashSet 的值都是存储在 HashMap 中的。在 HashSet 的构造方法中会初始化一个 HashMap 对象,HashSet 不允许值重复。因此,HashSet 的值是作为 HashMap 的 key 存储在 HashMap 中的,当存储的值已经存在时返回 false。

11、线程和进程有什么区别?

通常一个进程都有若干个线程,至少包含一个线程。

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小
  • 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程
  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮
  • 执行过程:每个独立的进程有程序运行的入口. 顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

12、有几种方式创建多线程

第一种方式:继承 Thread 类,并重写该类的 run() 方法,然后创建线程对象,调用线程对象的 start() 方法启动线程。

第二种方式:实现 Runnable 接口,重写该接口的 run() 方法,然后创建实现类的实例,并将此实例作为 Thread 的 target 来创建线程对象,调用线程对象的 start() 方法启动线程。

第三种方式:通过 Callable 和 Future 创建线程。实现Callable接口,重写 call() 方法,创建一个Callable的线程任务对象,把Callable的线程任务对象包装成一个未来任务对象,把未来任务对象包装成线程对象,调用线程的start()方法启动线程

第四种方式:通过线程池创建线程

Thread和Runnable的区别

总结:

实现Runnable接口比继承Thread类所具有的优势:

  1. 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
  2. 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU. 代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  3. 劣势:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法

Runnable 和 Callable 有什么区别?

  1. Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()
  2. Callable的任务执行后可返回值,而Runnable的任务是不能返回值的
  3. Call方法可以抛出异常,run方法不可以
  4. 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

13、线程的生命周期/线程有哪些状态

线程通常有五种状态:创建、就绪、运行、阻塞和死亡状态

阻塞情况又分为三种:

  1. 等待阻塞:运行的线程执行 wait 方法,该线程会释放占用的所有资源,JVM会把该线程放入 "等待池"中,进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用 notify 或者 notifyAll 方法才能被唤醒,wait 是object 类的方法。
  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入 "锁池"中。
  3. 其他阻塞:运行的线程执行 sleep 或者 join 方法,或者发出了 I/O请求时,JVM 会把该线程设置为阻塞状态。当 sleep 状态超时、join 等待线程终止或者超时、或者 I/O处理完毕时,线程重新转入就绪状态。 sleep 是 Thread 类的方法。

  1. 新建状态(New) : 新创建了一个线程对象
  2. 就绪状态(runnable) : 线程对象创建后,其他线程调用了该对象的 start 方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权
  3. 运行状态(Running) : 就绪状态的线程获取了 CPU,执行程序代码
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态
  5. 死亡状态(Dead) :线程执行完了或者因异常退出了 run 方法,该线程结束生命周期。

14、为什么要使用多线程呢

先从总体上来说:

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

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

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

16、说说线程的生命周期和状态

线程有6种状态:

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

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态

17、什么是线程死锁,如何避免线程死锁

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

死锁产生的四个必要条件:

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

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件 :一次性申请所有的资源
  2. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
  3. 破坏循环等待条件 :靠按序申请资源来预防

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

  • 锁排序法:(必须回答出来的点) 指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁? 通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。

18、为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用Thread类的run方法

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

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行(run 方法只是thread 的一个普通方法调用,还是在主线程里执行。)

线程的 run() 和 start() 有什么区别?

  1. 每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,方法 run() 称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程
  2. start() 方法来启动一个线程,真正实现了多线程运行。

19、谈一下你对 volatile 关键字的理解

volatile 关键字是用来保证有序性和可见性的。这跟 Java 内存模型有关。我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流水线阻塞,提高 CPU 的执行效率。这就需要有一定的顺序和规则来保证,不然程序员自己写的代码都不知道对不对了

被 volatile 修饰的共享变量,具有以下两点特性:

  1. 保证了不同线程对该变量操作的内存可见性
  2. 禁止指令重排序

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证

20、sleep()方法和 wait()方法

首先明白两个概念:锁池和等待池

  • 锁池:所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。
  • 等待池 :当我们调用 wait() 方法后,线程会放到等待池中,等待池的线程是不会去竞争同步锁。只有调用了 notify()notifyAll() 方法后等待池的线程才会开始去竞争锁, notify() 是随机从等待池选出一个线程放到锁池,而 notifyAll() 方法是将等待池的所有线程放到锁池当中。

区别:

  • sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
  • wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。
  • sleep 方法没有释放锁,而 wait 方法释放了锁 。
  • sleep 通常被用于暂停执行Wait 通常被用于线程间交互/通信
  • sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法

相同:

  • 两者都可以暂停线程的执行。

21、yield() 和 join() 方法的区别

  • yield() 执行后线程直接进入就绪状态,马上释放 cpu,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调入还会让这个线程获取到执行权继续执行。就是让别的线程去抢cpu,如果其他线程抢不到cpu,那么仍然是这个线程执行
  • join() 执行后线程进入阻塞状态,例如在线程B调用线程A的 join() ,那么线程 B会进入到阻塞队列,直到线程A结束或中断线程
public class Hello 
    public static void main(String[] args) throws InterruptedException 
        Thread t1 = new Thread(new Runnable() 
            @Override
            public void run() 
                try 
                    Thread.sleep(3000);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                System.out.println("22222");
            
        );
        t1.start();
        // 调用 t1的join()方法,会将 main 线程进行阻塞
        t1.join();
        //所以会先打印t1的内容 22222,再打印main的内容 1111
        System.out.println("1111");
    

22、线程阻塞的三种情况

当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,直到线程进入可运行状态(Runnable),才有机会再次获得 CPU 时间片转入 RUNNING 状态。一般来讲,阻塞的情况可以分为如下三种:

  1. 等待阻塞:RUNNING 状态的线程执行 Object.wait() 方法后,JVM 会将线程放入等待序列
  2. 同步阻塞:RUNNING 状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入锁池
  3. 其他阻塞:RUNNING 状态的线程执行 Thread.sleep(long ms)Thread.join() 方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep() 状态超时,join() 等待线程终止或超时. 或者 I/O 处理完毕时,线程重新转入可运行状态(RUNNABLE

23、线程死亡的三种方式

  1. 正常结束:run() 或者 call() 方法执行完成后,线程正常结束
  2. 异常结束:线程抛出一个未捕获的 ExceptionError,导致线程异常结束
  3. 调用 stop():直接调用线程的 stop() 方法来结束该线程,但是一般不推荐使用该种方式,因为该方法通常容易导致死锁

24、如何使用synchronized

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块:
  • 修饰实例方法(锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() 
    //业务代码

  • 修饰静态方法(锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() 
    //业务代码

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

  • 修饰代码块(锁当前对象/类):对括号里指定的对象/类加锁
    • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
    • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) 
    //业务代码

总结:

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

25、Synchronized的作用有哪些

  1. 原子性:确保线程互斥的访问同步代码
  2. 可见性:保证共享变量的修改能够及时可见
  3. 有序性:有效解决重排序问题

26、synchronized和volatile的区别是什么

volatile 解决的是内存可见性问题,会使得所有对 volatile 变量的读写都直接写入主存,即 保证了变量的可见性。``synchronized` 既保证了原子性,也保证了可见性。

区别:

  1. volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
  2. volatile 仅能使用在变量级别;synchronized 则可以使用在 变量. 方法. 和类级别的
  3. volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性
  4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
  5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化

27、synchronized和Lock有什么区别

  • synchronized 可以给类. 方法. 代码块加锁;而 lock 只能给代码块加锁
  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到

28、构造方法可以用 synchronized 修饰吗

**构造方法不能使用 synchronized 关键字修饰。**构造方法本身就属于线程安全的,不存在同步的构造方法一说

29、ThreadLocal有什么用

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题

  1. Java 的 Web 项目大部分都是基于 Tomcat。每次访问都是一个新的线程,每一个线程都独享一个 ThreadLocal,我们可以在接收请求的时候 set 特定内容,在需要的时候 get 这个值
  2. ThreadLocal 提供 get 和 set 方法,为每一个使用这个变量的线程都保存有一份独立的副本
  • get() 方法是用来获取 ThreadLocal 在当前线程中保存的变量副本
  • set() 用来设置当前线程中变量的副本
  • remove() 用来移除当前线程中变量的副本
  • initialValue() 是一个 protected 方法,一般是用来在使用时进行重写的,如果在没有 set 的时候就调用 get,会调用 initialValue 方法初始化内容

在那些场景下会使用 ThreadLocal?

  1. 数据库连接、处理数据库事务

    • 每个线程都有一个连接对象副本,不用担心造成线程安全问题,进行回滚或者其他操作,都不会受到干扰。
  2. 数据跨层传递

    • 通过静态方法传递参数,避免参数传递的麻烦。在调用 API 接口的时候传递了一些公共参数,这些公共参数携带了一些设备信息(是安卓还是 ios),服务端接口根据不同的信息组装不同的格式数据返回给客户端。假定服务器端需要通过设备类型(device)来下发下载地址,我们只要在返回数据的时候判断好是什么类型的客户端就好了。上面这种场景就可以将传进来的参数 device 设置到 ThreadLocal 中。用的时候取出来就行。避免了参数的层层传递

30、知道ThreadLocal内存泄漏问题吗

ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用(弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存)。弱引用比较容易被回收。因此,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是因为ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会**「造成了内存泄漏问题」**。

如何**「解决内存泄漏问题」**?使用完ThreadLocal后,及时调用remove()方法释放内存空间。

31、为什么要用线程池

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

JDK中提供了哪些并发容器?

JDK 提供的这些容器大部分在 java.util.concurrent 包中:

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

32、如何创建线程池

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

Executors创建线程池对象的弊端如下:FixedThreadPool 和 SingleThreadExecutor :允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM(内存用完)。CachedThreadPool: 允许创建的线程数量为Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

方式一:通过 Executor 框架的工具类 Executors 来实现,我们可以创建三种类型的 ThreadPoolExecutor:

  • FixedThreadPool

    • 创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。
    • FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
  • SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

  • CachedThreadPool:

    • 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    • 这种类型的线程池特点是:如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统OOM

方式二:通过 ThreadPoolExecutor 的构造方法实现

创建线程池的参数有哪些?

  1. corePoolSize : 核心线程大小。线程池一直运行,核心线程就不会停止。
  2. maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。
  3. keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以,如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。
  4. TimeUnit(线程活动保持时间的单位)
  5. workQueue(任务队列):用于保存等待执行的任务的阻塞队列。

线程池中的线程数一般怎么设置?需要考虑哪些问题?

主要考虑下面几个方面:

  1. 线程池中线程执行任务的性质

    计算密集型的任务比较占 cpu,所以一般线程数设置的大小 等于或者略微大于 cpu 的核数;但 IO 型任务主要时间消耗在 IO 等待上,cpu 压力并不大,所以线程数一般设置较大。

  2. cpu 使用率

    当线程数设置较大时,会有如下几个问题:第一,线程的初始化,切换,销毁等操作会消耗不小的 cpu 资源,使得 cpu 利用率一直维持在较高水平。第二,线程数较大时,任务会短时间迅速执行,任务的集中执行也会给 cpu 造成较大的压力。第三, 任务的集中支持,会让 cpu 的使用率呈现锯齿状,即短时间内 cpu 飙高,然后迅速下降至闲置状态,cpu 使用的不合理,应该减小线程数,让任务在队列等待,使得 cpu 的使用率应该持续稳定在一个合理,平均的数值范围。

  3. 内存使用率

    线程数过多和队列的大小都会影响此项数据,队列的大小应该通过前期计算线程池任务的条数,来合理的设置队列的大小,不宜过小,让其不会溢出,因为溢出会走拒绝策略,多少会影响性能,也会增加复杂度。

  4. 下游系统抗并发能力

    多线程给下游系统造成的并发等于你设置的线程数,如果访问的是下游系统的接口,你就得考虑下游系统是否能抗的住这么多并发量,不能把下游系统打挂了。

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

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

33、线程池执行任务的流程

  1. 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
  2. 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务
  3. 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。

34、核心线程数和最大线程数有什么区别

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数:

  1. keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;

  2. unit : keepAliveTime 参数的时间单位。

  3. threadFactory :executor 创建新线程的时候会用到。

  4. handler :饱和策略

35、线程池的饱和策略有哪些

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

  • ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException来拒绝新任务的处理。

  • ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

  • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。

  • ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求

举个例子:Spring 通过 ThreadPoolTaskExecutor 创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy。在默认情况下,ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy。当最大池被填满时,此策略为我们提供可伸缩队列。

36、如何设置线程池的大小

如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

37、Executor和Executors的区别

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

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

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

38、谈谈乐观锁和悲观锁

乐观锁是CAS [Compare And Swap(比较再交换)],悲观锁是 synchronized

  • Synchronized是从悲观的角度出发(悲观锁):总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。jdk中的ReentrantLock也是一种悲观锁。性能较差!!
  • CAS是从乐观的角度出发:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。CAS这种机制我们也可以将其称之为乐观锁。综合性能较好!

两种锁的使用场景: