Java面试

本文涉及的产品
云数据库 RDS SQL Server,基础系列 2核4GB
云数据库 Tair(兼容Redis),内存型 2GB
传统型负载均衡 CLB,每月750个小时 15LCU
简介: Java面试

底层开始篇-GC

1.什么是垃圾回收?

   垃圾回收(Garbage Collection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。

注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。但不能说是回收对象,可以理解为一种文字游戏。

分析:

  引用:如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。(引用都有哪些?对垃圾回收又有什么影响?

  垃圾:无任何对象引用的对象(怎么通过算法找到这些对象呢?)。

  回收:清理“垃圾”占用的内存空间而非对象本身(怎么通过算法实现回收呢?)。

  发生地点:一般发生在堆内存中,因为大部分的对象都储存在堆内存中(堆内存为了配合垃圾回收有什么不同区域划分,各区域有什么不同?)。

  发生时间:程序空闲时间不定时回收(回收的执行机制是什么?是否可以通过显示调用函数的方式来确定的进行回收过程?

   带着这些问题我们开始进一步的分析。

2.Java中的对象引用

(1)强引用(Strong Reference):如“Object obj = new Object()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。

(2)软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。

(3)弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。

(4)虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。

3.判断对象是否是垃圾的算法。

     Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:(1)找到所有存活对象;(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。

引用计数算法(Reference Counting Collector)

堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。   
优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。  
缺点: 难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。    早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。

根搜索算法(Tracing Collector)

首先了解一个概念:***根集(Root Set)***
    所谓根集(Root Set)就是正在执行的Java程序可以访问的引用变量(注意:不是对象)的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。
s1.study();
s1.name 
    这种算法的基本思路:
 (1)通过一系列名为“GC Roots”的对象作为起始点,寻找对应的引用节点。
 (2)找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。
 (3)重复(2)。
 (4)搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。
    Java和C#中都是采用根搜索算法来判定对象是否存活的。
**标记可达对象:**
    JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。 
Book book1 = new Book();
fun(book1)
public static  void fun(Book book2){
  book2.setName("....")
}


Java的堆内存(Java Heap Memory)

Java的堆内存基于Generation算法(Generational Collector)划分为新生代、年老代和持久代。新生代又被进一步划分为Eden和Survivor区,最后Survivor由FromSpace(Survivor0)和ToSpace(Survivor1)组成。所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。
 分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的代采取不同的回收算法 进行垃圾回收(GC),以便提高回收效率。

Java的内存空间除了堆内存还有其他部分:

1)栈

   每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、参数和中间结果。

2)本地方法栈

   用于支持native方法的执行,存储了每个native方法调用的状态。

4)方法区

   存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(PermanetGeneration)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。

堆内存分配区域:

1.年轻代(Young Generation)

几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0,Survivor1)区。大部分对象在Eden区中生成。当新对象生成,Eden Space申请失败(因为空间不足等),则会发起一次GC(Scavenge GC)。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了时,则将Eden区和Survivor0区存活对象复制到另一个Survivor1区,然后清空Eden和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空, 如此往复。当Survivor1区不足以存放 Eden和Survivor0的存活对象时,就将存活对象直接存放到老年代。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。

2.年老代(Old Generation)

 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。比如:

     byte[] data = new byte[4*1024*1024]

     这种一般会直接在老年代分配存储空间。

     当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。

3.持久代(Permanent Generation)

用于存放静态文件(class类、方法)和常量等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

     永久代空间在Java SE8特性中已经被移除。取而代之的是元空间(MetaSpace)。因此不会再出现“java.lang.OutOfMemoryError: PermGen error”错误。

4.垃圾回收执行时间和注意事项

 GC分为Scavenge GC和Full GC。

Scavenge GC :发生在Eden区的垃圾回收。

Full GC :对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。

   有如下原因可能导致Full GC:

   1.年老代(Tenured)被写满;

   2.持久代(Perm)被写满;

   3.System.gc()被显示调用;

   4.上一次GC之后Heap的各域分配策略动态变化.

5.与垃圾回收时间有关的两个函数

System.gc()方法

使用System.gc()可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求Java的垃圾回收。在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式如下:    java -verbosegc classfile    需要注意的是,调用System.gc()也仅仅是一个请求(建议)。JVM接受这个消息后,并不是立即做垃圾回收,而只是对几个垃圾回收算法做了加权,使垃圾回收操作容易发生,或提早发生,或回收较多而已。

finalize()方法

 概述:在JVM垃圾回收器收集一个对象之前,一般要求程序调用适当的方法释放资源。但在没有明确释放资源的情况下,Java提供了缺省机制来终止该对象以释放资源,这个方法就是finalize()。它的原型为:

protected void finalize() throws Throwable

在finalize()方法返回之后,对象消失,垃圾收集开始执行。原型中的throws Throwable表示它可以抛出任何类型的异常

触发主GC的条件

  1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。

   2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。

 3)在编译过程中作为一种优化技术,Java 编译器能选择给实例赋 null 值,从而标记实例为可回收。

由于是否进行主GC由JVM根据系统环境决定,而系统环境在不断的变化当中,所以主GC的运行具有不确定性,无法预计它何时必然出现,但可以确定的是对一个长期运行的应用来说,其主GC是反复进行的。

减少GC开销的措施

(1)不要显式调用System.gc()

此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

(2)尽量减少临时对象的使用    

临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

(3)对象不用时最好显式置为Null    

一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

(4)尽量使用StringBuilder,而不用String来累加字符串

(5)能用基本类型如int,long,就不用Integer,Long对象    

基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

(6)尽量少用静态对象变量static  

静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

GC性能调优

    1、 Java虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序(尤其服务器端)的性能和稳定性有着非常重要的影响。性能调优需要具体情况具体分析,而且实际分析时可能需要考虑的方面很多,这里仅就一些简单常用的情况作简要介绍。

    2、 我们可以通过给Java虚拟机分配超大堆(前提是物理机的内存足够大)来提升服务器的响应速度,但分配超大堆的前提是有把握把应用程序的Full GC频率控制得足够低,因为一次Full GC的时间造成比较长时间的停顿。控制Full GC频率的关键是保证应用中绝大多数对象的生存周期不应太长,尤其不能产生批量的、生命周期长的大对象,这样才能保证老年代的稳定。

    3、 Direct Memory在堆内存外分配,而且二者均受限于物理机内存,且成负相关关系。因此分配超大堆时,如果用到了NIO机制分配使用了很多的Direct Memory,则有可能导致Direct Memory的OutOfMemoryError异常,这时可以通过-XX:MaxDirectMemorySize参数调整Direct Memory的大小。

    4、 除了Java堆和永久代以及直接内存外,还要注意下面这些区域也会占用较多的内存,这些内存的总和会受到操作系统进程最大内存的限制:1、线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(纵向无法分配,即无法分配新的栈帧)或OutOfMemoryError(横向无法分配,即无法建立新的线程)。

     5、Socket缓冲区:每个Socket连接都有Receive和Send两个缓冲区,分别占用大约37KB和25KB的内存。如果无法分配,可能会抛出IOException:Too many open files异常。关于Socket缓冲区的详细介绍参见我的Java网络编程系列中深入剖析Socket的几篇文章。

     JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中。

     虚拟机和GC:虚拟机和GC的代码执行也要消耗一定的内存。

底层开篇-JVM

1. 什么是JVM?

1、JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

2、Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

2. JRE/JDK/JVM是什么关系?

1、JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

2、JDK(Java Development Kit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

3、JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

3. JVM原理

JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。


java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

4. JVM执行程序的过程

  1. 加载.class文件----》 2) 管理并分配内存 -----》3) 执行垃圾收集

JRE(java运行时环境)由JVM构造的java程序的运行环,也是Java程序运行的环境,但是他同时一个操作系统的一个应用程序一个进程,因此他也有他自己的运行的生命周期,也有自己的代码和数据空间。JVM在整个jdk中处于最底层,负责于操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境,因此也就虚拟计算机。

操作系统装入JVM是通过jdk中Java.exe来完成,通过下面4步来完成JVM环境:

1) 创建JVM装载环境和配置

2) 装载JVM.dll

3) 初始化JVM.dll并挂界到JNIENV(JNI调用接口)实例

4) 调用JNIEnv实例装载并处理class类。

5. JVM的生命周期

  1. JVM实例对应了一个独立运行的java程序它是进程级别

a) 启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void
main(String[] args)函数的class都可以作为JVM实例运行的起点

b) 运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以表明自己创建的线程是守护线程
c) 消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出

  1. JVM执行引擎实例则对应了属于用户运行程序的线程它是线程级别的

6. JVM的体系结构

  • 类装载器(ClassLoader)(用来装载.class文件)
  • 执行引擎(执行字节码,或者执行本地方法)
  • 运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)

7. JVM运行时数据区

第一块:PC寄存器

PC寄存器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则PC寄存器中不存储任何信息。

第二块:JVM栈

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

int a = 10;  // 栈:a, 10

stu1 = new Student();// 栈: stu1  ; 堆: new Student()对象

int[] a1 = {10,20,30,40}; // 栈: a1  ;  堆: 数组的值

char[] xx = new char[16];

第三块:堆(Heap)

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

(1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

(2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

(3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

(4) 所有新创建的Object 都将会存储在新生代Yong Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。

第四块:方法区域(Method Area)

(1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

(2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

第五块:运行时常量池(Runtime Constant Pool)

存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

第六块:本地方法堆栈(Native Method Stacks)

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

JVM参数设置



-Xms设置堆的最小空间大小。
-Xmx设置堆的最大空间大小。
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小


没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制。

老年代空间大小=堆空间大小-年轻代大空间大小

VM内存区域总体分两类,heap区 和 非heap 区 。

heap区: 堆区分为Young Gen(新生代),Tenured Gen(老年代-养老区)。其中新生代又分为Eden Space(伊甸园)、Survivor Space(幸存者区)。
非heap区: Code Cache(代码缓存区)、Perm Gen(永久代)、Jvm Stack(java虚拟机栈)、Local Method Statck(本地方法栈)。

(1)为什么要区分新生代和老生代?

堆中区分的新生代和老年代是为了垃圾回收,新生代中的对象存活期一般不长,
而老年代中的对象存活期较长,所以当垃圾回收器回收内存时,新生代中垃圾回收效果较好,
会回收大量的内存,而老年代中回收效果较差,内存回收不会太多。

(2)不同代采用的算法区别?

基于以上特性,新生代中一般采用复制算法,因为存活下来的对象是少数,
所需要复制的对象少,而老年代对象存活多,不适合采用复制算法,
一般是标记整理和标记清除算法。
因为复制算法需要留出一块单独的内存空间来以备垃圾回收时复制对象使用,
所以将新生代分为eden区和两个survivor区,每次使用eden和一个survivor区,
另一个survivor作为备用的对象复制内存区。

请看下面 :

对于jvm内存配置参数:

-Xmx10240m -Xms10240m -Xmn5120m -XXSurvivorRatio=3

其最小内存值和Survior区总大小分别是: 10240m,2048m

我们只需要知道Survior区有两个,就是图中的S0和S1,
而Eden区只用一个, -XXSurvivorRatio参数是Eden区和单个Survior区的比例,
所以应该有(3+1+1)*Survior=5012m,图中问的是Survior总大小(需乘2)

(3)修改JVM参数

<1>IDEA 可以在 Run-edit configration.. 中设置参数,如下所示

<2>JetBrains\IntelliJ IDEA 2021.1.1\bin\idea64.exe.vmoptions

-Xms128m
-Xmx750m
-XX:ReservedCodeCacheSize=512m
-XX:+UseG1GC
-XX:SoftRefLRUPolicyMSPerMB=50
-XX:CICompilerCount=2
-XX:+HeapDumpOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow
-ea
-Dsun.io.useCanonCaches=false
-Djdk.http.auth.tunneling.disabledSchemes=""
-Djdk.attach.allowAttachSelf=true
-Djdk.module.illegalAccess.silent=true
-Dkotlinx.coroutines.debug=off

synchronized底层实现原理

synchronized关键字的作用

synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

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

三种使用方式:

(1)修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

(2)修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

(3)修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

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

1. 为什么说synchronized是一个重量级锁

synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。

2. synchronized底层实现原理

同步方法通过ACC_SYNCHRONIZED 关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。

同步代码块通过monitorenter和monitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。每个对象自身维护着一个被加锁次数的计数器,当计数器不为0时,只有获得锁的线程才能再次获得锁。

public class MyOrder {
    // ACC_SYNCHRONIZED  隐式监视器锁
    public synchronized void fun1(){
        System.out.println("fun1111111111111");
    }
    // 显示监视器锁 monitorenter , monitorexit
    public  void fun2(){
        synchronized (this){
            System.out.println("fun22222222222");
        }
    }
}

(1)同步代码块

反编译:

原理:

基于对象的监视器(ObjectMonitor),在字节码文件里面可以看到,在同步方法执行前后,有两个指令,进入同步方法前monitorenter,方法执行完成后monitorexit;

对象都有一个监视器ObjectMonitor,这个监视器内部有很多属性,比如当前等待线程数、计数器、当前所属线程等;其中计数器属性就是用来记录是否已被线程占有,方法执行到monitorenter时,计数器+1,执行到monitorexit时,计数器-1,线程就是通过这个计数器来判断当前锁对象是否已被占用(0为未占用,此时可以获取锁);一个synchronize锁会有两个monitorexit,这是保证synchronize能一定释放锁的机制,一个是方法正常执行完释放,一个是执行过程发生异常时虚拟机释放。

monitor妈奈塔儿:

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter(莫逆塔安特儿)时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:

若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者);
若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1 ;
若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
monitorenter:

synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner(欧娜儿):拥有这把锁的线程,recursions(瑞康申)会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待。

monitorexit:exit(安可it)

能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。同时,monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

总结:synchronized同步语句块的实现使⽤的是 monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏monitorenter指令时,线程试图获取锁也就是获取monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

(2)同步方法

同样,进行反编译:

同步方法在反汇编后,会增加 ACC_SYNCHRONIZED 修饰。会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

【JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。】

synchronized的特性

原子性、可见性、有序性、可重入性

1.1 原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。

被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

注意!面试时经常会问比较synchronized和volatile,它们俩特性上最大的区别就在于原子性,volatile不具备原子性。

原子性不可被打断,一旦被打断就会出现脏读

1.2 可见性

可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

1.3 有序性

有序性值程序执行的顺序按照代码先后执行。

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

1.4 可重入性

synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

基础开篇

面向对象有什么特征,请分别解释。

答案:封装、继承、多态

封装:封装的主要作用是为了实现代码之间的“高内聚、低耦合”,通常认为封装是把数据和操作数据的方法绑定起来,对数据访问只能用过已定义的接口,一般来说会将类的成员属性定义为私有,这样其他类就无法直接访问,实现了数据的安全性。

继承:继承是指获取已有的类的继承信息并创建类的过程,子类承父类的属性和行为。同时可以精选属性的添加和行为的修改。提高了代码的可重用性和扩展性。

多态:指允许不同子类的对象对同一消息做出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做不了相同的事情。多态分为编译时的多态和运行时的多态,方法的重载其实就是编译时多态的体现,而方法重写则是运行时多态的体现。

Java的访问修饰符有哪些,并解释各自的作用域

1.排序都有哪几种方法?请列举。口述用JAVA实现快速排序。

冒泡排序:

排序的方法:插入排序、冒泡排序、快速排序、选择排序、归并排序。

使用快速排序方法对a[0:n-1]排序:

从a[0:n-1]中选择一个元素作为middle,该元素为支点

把余下的元素分割为两端left和right,使得left中的元素都小于等于支点,而right中的元素都是大于支点。

递归的使用快速排序方法对left进行排序

递归的使用快速排序方法对right进行排序。

所得结果为left+middle+right

/**
- 冒泡排序
- @param arr 需要排序的数组 
*/
public void bubbleSort(int[] arr){ 
    for(int i= 0;i<arr.length;i++){
        for(int j = 0;j<arr.length-i-1;j++){
            if(arr[j]  > arr[j+1]){
                int temp;
                temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }        
        }
        System.out.print(arr[i]  + ",");
    }
}

2. String底层是怎么实现的

String类的一个最大特性是不可修改性,而导致其不可修改的原因是在String内部定义了一个常量数组final char数组,因此每次对字符串的操作实际上都会另外分配分配一个新的常量数组空间

· 初始化

public final class String    implements java.io.Serializable, Comparable<String>, CharSequence {
   //数组定义为常量,不可修改   
   private final char value[];
    public String() {
        this.value = new char[0];
    }
//实例化字符串
public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

3.  String,StringBuffer的和StringBuilder的区别?

String:  字符串不可变,适用于字符串内容不经常发生改变的时候 final char[] ch

StringBuilder: 字符串可变,适用于字符串内容经常发生改变的时候,适用于单线程(线程不安全),在单线程中,执行效率较高

StringBuffer:字符串可变,适用于字符串内容经常发生改变的时候,适用于多线程(线程安全)

(讲到此处就可以,以下是理解)

详细分析:
String 类型和 StringBuffer 类型的主要性能区别其实在于 String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生影响,特别当内存中无引用对象多了以后, JVM 的 GC 就会开始工作,那速度是一定会相当慢的。 
 而如果是使用 StringBuffer 类则结果就不一样了,每次结果都会对 StringBuffer 对象本身进行操作,而不是生成新的对象,再改变对象引用。所以在一般情况下我们推荐使用 StringBuffer ,特别是字符串对象经常改变的情况下。而在某些特别情况下, String 对象的字符串拼接其实是被 JVM 解释成了 StringBuffer 对象的拼接,所以这些时候 String 对象的速度并不会比 StringBuffer 对象慢,而特别是以下的字符串对象生成中, String 效率是远要比 StringBuffer 快的: 
 String S1 = “This is only a” + “ simple” + “ test”; 
 StringBuffer Sb = new StringBuilder(“This is only a”).append(“ simple”).append(“ test”); 
 你会很惊讶的发现,生成 String S1 对象的速度简直太快了,而这个时候 StringBuffer 居然速度上根本一点都不占优势。其实这是 JVM 的一个把戏,在 JVM 眼里,这个 
 String S1 = “This is only a” + “ simple” + “test”; 其实就是: 
 String S1 = “This is only a simple test”; 所以当然不需要太多的时间了。但大家这里要注意的是,如果你的字符串是来自另外的 String 对象的话,速度就没那么快了,譬如: 
String S2 = “This is only a”; 
String S3 = “ simple”;
String S4 = “ test”; 
String S1 = S2 +S3 + S4; 
这时候 JVM 会规规矩矩的按照原来的方式去做  
在大部分情况下 StringBuffer > String 
//-------------------------StringBuffer ----------------------
Java.lang.StringBuffer线程安全的可变字符序列。一个类似于 String 的字符串缓冲区,但不能修改。虽然在任意时间点上它都包含某种特定的字符序列,但通过某些方法调用可以改变该序列的长度和内容。 
可将字符串缓冲区安全地用于多个线程。可以在必要时对这些方法进行同步,因此任意特定实例上的所有操作就好像是以串行顺序发生的,该顺序与所涉及的每个线程进行的方法调用顺序一致。 
StringBuffer 上的主要操作是 append 和 insert 方法,可重载这些方法,以接受任意类型的数据。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符追加或插入到字符串缓冲区中。append 方法始终将这些字符添加到缓冲区的末端;而 insert 方法则在指定的点添加字符。 
例如,如果 z 引用一个当前内容是“start”的字符串缓冲区对象,则此方法调用 z.append("le") 会使字符串缓冲区包含“startle”,而 z.insert(4, "le") 将更改字符串缓冲区,使之包含“starlet”。 
在大部分情况下 StringBuilder > StringBuffer 
java.lang.StringBuilder一个可变的字符序列是5.0新增的。此类提供一个与 StringBuffer 兼容的 API,但不保证同步。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。如果可能,建议优先采用该类,因为在大多数实现中,它比 StringBuffer 要快。两者的方法基本相同.

4. Java的访问修饰符有哪些?

public:使用public修饰,对所有类可以访问。 
protected:使用protected修饰,对同一包内和不同包所有子类可以访问。
缺省:不使用任何修饰符,在同一包内可以访问。 
private:使用private修饰,在同一类内可以访问

5.  接口和抽象类的区别是什么?(能讲多少讲多少)

应用方面的区别:

接口更多的是在系统架构设计方法发挥作用,主要用于定义模块之间的通信契约

抽象类在代码实现方面发挥作用,可以实现代码的重用。

( 举项目中的例子讲)

语法方面的区别:

(1)抽象类和接口都不能直接实例化,如果要实例化,抽象类对象必须指向实现所有抽象方法的子类对象, 接口对象必须指向实现所有接口方法的类对象。

(2)抽象类要被子类继承,接口要被类实现。

(3)接口中只有全局常量与抽象方法;

抽象类中可以有抽象方法与具体方法实现,可以有类变量与实例变量

(4)接口可继承接口,并可多继承接口;但抽象类只能单继承

6. Java 支持多继承么?

不支持,Java 类不支持多继承。每个类都只能继承一个类,但是可以实现多个接口

7. Java中的泛型是什么 ? 使用泛型的好处是什么?

泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法。

Java语言引入泛型的好处是安全简单。泛型的好处是在编译的时候检查类型安全,并且所有的强制转换都是自动和隐式的,提高代码的重用率。

8.  try与return

try{}里有一个return语句,那么紧跟在这个try后的finally{}里的code会不会被执行,什么时候被执行,在return前还是后?

int xx = 10;
try{
  return xx;
}finally{ 
  xx = 20;
}
a) finally: 用于释放资源,节省内存,语法上无论是否有异常,都会执行
b) return : 方法结束
c) 先执行return ,再执行finally

9. 在finally中,修改return 的返回值变量,最后返回值究竟有没有发生改变?

若返回值类型是:基本数据类型,finally 中修改返回值变量,返回值不会改变【值传递】

若返回值类型是:引用数据类型,finally 中修改返回值变量,返回值会改变【引用传递】

10. final, finally, finalize 的区别?

final:修饰符有三种用法:

(1)修饰类:表示该类不能被继承,即不能有子类

(2)修饰方法:表示方法不能被重写;

(3)修饰变量:表示变量只能一次赋值以后值不能被修改(常量)。

finally:用在异常结构中

通常放在try…catch的后面构造总是执行代码块,可以将释放外部资源的代码写在finally块中,即finally的作用是释放资源,节省内存。

finalize:Object类中GC相关的方法

Object类中的方法,Java中允许使用finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写finalize() 方法可以整理系统资源或者执行其他清理工作。

11.  ==与equals的区别

(1)基本数据类型比较,用双等号(==),比较的是他们的值。

(2)引用数据类型比较,

  a. 使用==比较,比较的是他们在内存中的存放地址,所以,除非是同一个new出来的对象,比较后的结果为true,否则结果为false。( JAVA当中所有的类都是继承于Object这个基类的,在Object中的基类中定义了一个equals的方法,这个方法的初始行为是比较对象的内存地 址,但在一些类库当中这个方法被覆盖掉了,如String,Integer,Date在这些类当中equals有其自身的实现,而不再是比较类在堆内存中的存放地址了。--这部分帮助理解)

b. 使用equals比较,若没有重写Object类的equals方法,比较还是内存地址(==理解);

若重写Object类的equals方法,equals比较的是堆中内容是否相等,即两个对象的内容是否相同。

12.  自动装箱与拆箱

boxing装箱:将基本类型---转换成---对应的包装类;

unboxing拆箱:将包装类型---转换成---基本数据类型;

Java使用自动装箱和拆箱机制,节省了常用数值的内存开销和创建对象的开销,提高了效率,由编译器来完成,编译器会在编译期根据语法决定是否进行装箱和拆箱动作。

13. 什么是反射API?它是如何实现的?

反射是指在运行时能查看一个类的状态及特征,并能进行动态管理的功能。这些功能是通过一些内建类的反射API提供的,比如Class,Method,Field, Constructors等。使用的例子:使用Java反射API的getName方法可以获取到类名。

14. 类加载器工作机制

1.装载:将Java二进制代码导入jvm中,生成Class文件。

2.连接:

a)校验:检查载入Class文件数据的正确性

b)准备:给类的静态变量分配存储空间

c)解析:将符号引用转成直接引用

3:初始化:对类的静态变量,静态方法和静态代码块执行初始化工作。

双亲委派模型:类加载器收到类加载请求,首先将请求委派给父类加载器完成 用户自定义加载器->应用程序加载器->扩展类加载器->启动类加载器。

15. 写一个 Singleton 单例模式

在我们日常的工作中经常需要在应用程序中保持一个唯一的实例,如:IO处理,数据库操作等,由于这些对象都要占用重要的系统资源,所以我们必须限制这些实例的创建或始终使用一个公用的实例,这就是单例模式(Singleton)。

/*饿汉模式:第一种生成singleton优点:简单,缺点:生成的资源浪费*/
class singleton1{
    private singleton1(){};
    private static singleton1 s1=new singleton1();
    public static singleton1 getSingleton(){
      return s1;
   }
}
/*懒汉模式: 第二种优点:不占用资源,缺点:不是完全的线程安全,如果在if语句执行过后转移到另一线程执行,在转移回来的时候将不进行判断,又生成一次*/
class singleton2{
  private singleton2(){
  }
  private static singleton2 s2=null;
  public static synchronized singleton2 getSingleton(){
    if(s2==null)
      s2=new singleton2();
    return s2;

单例模式-线程安全

(1)多线程安全单例模式实例一(不使用同步锁)

public class Singleton {
    private static Singleton sin=new Singleton();    ///直接初始化一个实例对象
    private Singleton(){    ///private类型的构造函数,保证其他类对象不能直接new一个该对象的实例
    }
    public static Singleton getSin(){    ///该类唯一的一个public方法  
    return sin;
    }
}

上述代码中的一个缺点是该类加载的时候就会直接new 一个静态对象出来,当系统中这样的类较多时,会使得启动速度变慢 。现在流行的设计都是讲“延迟加载”,我们可以在第一次使用的时候才初始化第一个该类对象。所以这种适合在小系统。

(2)多线程安全单例模式实例二(使用同步方法)

public class Singleton {  
     private static Singleton instance;  
     private Singleton (){         
     }   
     public static synchronized Singleton getInstance(){    //对获取实例的方法进行同步
       if (instance == null)     
         instance = new Singleton();
       return instance;
     }
 }


上述代码中的一次锁住了一个方法, 这个粒度有点大 ,改进就是只锁住其中的new语句就OK。就是所谓的“双重锁”机制。

(3)多线程安全单例模式实例三(使用双重同步锁)


public class Singleton {  
     private static Singleton instance;  
     private Singleton (){
     }   
     public static Singleton getInstance(){    //对获取实例的方法进行同步
       if (instance == null){
           synchronized(Singleton.class){
               if (instance == null)
                   instance = new Singleton(); 
           }
       }
       return instance;
     }
 }

16. 字符流和字节流有什么区别?

要把一片二进制数据逐一输出到某个设备中,或者从某个设备中逐一读取一片二进制数据,不管输入输出设备是什么,我们要用统一的方式来完成这些操作,用一种抽象的方式进行描述,这个抽象描述方式起名为IO流,对应的抽象类为OutputStream和InputStream ,不同的实现类就代表不同的输入和输出设备,它们都是针对字节进行操作的。

在应用中,经常要完成是字符的一段文本输出去或读进来,用字节流可以吗?计算机中的一切最终都是二进制的字节形式存在。对于“中国”这些字符,首先要得到其对应的字节,然后将字节写入到输出流。读取时,首先读到的是字节,可是我们要把它显示为字符,我们需要将字节转换成字符。由于这样的需求很广泛,人家专门提供了字符流的包装类。

底层设备永远只接受字节数据,有时候要写字符串到底层设备,需要将字符串转成字节再进行写入。字符流是字节流的包装,字符流则是直接接受字符串,它内部将串转成字节,再写入底层设备,这为我们向IO设备写入或读取字符串提供了一些方便。

17. 多线程编程的好处是什么?

在多线程程序中,多个线程被并发的执行以提高程序的效率,CPU不会因为某个线程需要等待资源而进入空闲状态。多个线程共享堆内存(heap memory),因此创建多个线程去执行一些任务会比创建多个进程更好。

18. 三种创建多线程方式

基于什么创建

创建的方式

Thread类

继承Thread

Runnable接口

实现Runnable

接口

callable接口

实现callable

接口

实现callable接口

1.创建一个实现Callable接口的类。
2.在这个实现类中实现Callable接口的call()方法,并创建这个类的对象。
3.将这个Callable接口实现类的对象作为参数传递到FutureTask类的构造器中,创建FutureTask类的对象。
4.将这个FutureTask类的对象作为参数传递到Thread类的构造器中,创建Thread类的对象,并调用这个对象的start()方法。
class MyCallable implements Callable{
    @Override
    public Object call() throws Exception {
           System.out.println("实现Callable接口创建多线程");
           return null;
     }  
}
public class ThreadCreate3 {
    public static void main(String[] args) {
        MyCallable myCallable = new MyCallable();
        FutureTask futureTask = new FutureTask(myCallable);
        Thread thread = new Thread(futureTask);
        thread.start();
}

Runnable接口和Callable接口有何区别

相同点

  1. Runnable和Callable都是接口
  2. 都可以编写多线程程序
  3. 都采用Thread.start()启动线程

不同点

  1. Runnable接口run方法无返回值,Callable接口call方法有返回值,是个泛型,和Futrue和FutureTask配合用来获取异步执行结果。
  2. Runnable接口run方法只能抛出运行时的异常,且无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息。

:Callable接口支持返回执行结果,需要调用FutureTask.get()得到,此方方法堵塞主线程继续往下执行,如果不调用就不会堵塞。

19. 启动一个线程是用run()还是start()?

JVM启动时会有一个由主方法所定义的线程。可以通过创建Thread的实例来创建新的线程。每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。

Start()让线程进入就绪状态

Run() 是线程正在运行状态

20. volatile 关键字的作用

Java 提供了 volatile 关键字是线程同步的轻量级实现,用来保证可见性和有序性(禁止指令重排),volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

对于加了 volatile关键字的成员变量,在对这个变量进行修改时,全直接将CPU高级缓存中的数据送回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性
在对 volatile修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性
volatile可以和CAS 结合,来保证原子性。

21. volatile是怎样实现了?

1. volatile实现内存可见性原理:

一个问题:本地内存和主内存之间的值不一致,导致内存不可见。

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。将当前处理器缓存行的数据写回系统内存,这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效,当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

2. volatile实现有序性原理:

为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。

内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

22. 什么是CAS

CAS即Compare And Swap,比较并替换。是一条CPU并发原语,Java中可以通过CAS操作来保证原子性,它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。
CAS并发原语提现在Java语言中就是sun.misc.UnSafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我实现CAS汇编指令。这是一种完全依赖于硬件功能,通过它实现了原子操作。原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。
Unsafe:CAS的核心类,Java方法无法直接访问内存,需要通过本地方法native来访问,在Unsafe中所有方法都是native方法,用来直接操作内存,执行相应的任务。
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。类似于乐观锁。CAS自旋的概率会比较大,从而浪费更多的CPU资源。在这个过程中可能存在ABA问题:
当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次(A->B->A),而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。

23. ABA的问题的解决方式

ABA的解决方法也很简单,就是利用版本号。给变量加上一个版本号,每次变量更新的时候就把版本号加1,这样即使E的值从A—>B—>A,版本号也发生了变化,这样就解决了CAS出现的ABA问题。基于CAS的乐观锁也是这个实现原理。
JDK1.5时可以利用AtomicStampedReference类来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功
自旋:当多个线程同时操作一个共享变量时,只有一个线程可以对变量进行成功更新,其他线程均会失败,但是失败并不会被挂起,进行再次尝试,也就是自旋。Java中的自旋锁就是利用CAS来实现的。

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

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

25. 线程池的原理?

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。他的主要特点为:线程复用;控制最大并发数;管理线程。

重用存在的线程,减少对象创建销毁的开销,且提高了响应速度;有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能。

线程池类别:

  • newFixedThreadPool :一个定长线程池,可控制线程最大并发数。
  • newCachedThreadPool:一个可缓存线程池。
  • newSingleThreadExecutor:一个单线程化的线程池,用唯一的工作线程来执行任务。
  • newScheduledThreadPool:一个定长线程池,支持定时/周期性任务执行。

线程池尽量不要使用 Executors 去创建,

而是通过 ThreadPoolExecutor的方式去创建,因为Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等如果使用不当,会造成资源耗尽问题。直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险。

26. sleep() 和 wait() 有什么区别

两者都可以暂停线程的执行
类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
是否释放锁:sleep() 不释放锁;wait() 释放锁,并且会加入到等待队列中。
是否依赖synchronized关键字:sleep不依赖synchronized关键字,wait需要依赖synchronized关键字
用途不同:sleep 通常被用于休眠线程;wait 通常被用于线程间交互/通信,
用法不同:sleep() 方法执行完成后,不需要被唤醒,线程会自动苏醒,或者可以使用wait(longtimeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。

27.什么是线程死锁

死锁是指两个或两个以上进程(线程)在执行过程中,由于竞争资源或由于彼此通信造成的一种堵塞的现象,若无外力的作用下,都将无法推进,此时的系统处于死锁状态。

28. 形成死锁的四个必要条件

  1. 互斥条件:线程(进程)对所分配的资源具有排它性,即一个资源只能被一个进程占用,直到该进程被释放。
  2. 请求与保持条件:一个进程(线程)因请求被占有资源而发生堵塞时,对已获取的资源保持不放。
  3. 不剥夺条件:线程(进程)已获取的资源在未使用完之前不能被其他线程强行剥夺,只有等自己使用完才释放资源。
  4. 循环等待条件:当发生死锁时,所等待的线程(进程)必定形成一个环路,死循环造成永久堵塞。

29. 如何避免死锁

我们只需破坏形参死锁的四个必要条件之一即可。

  1. 破坏互斥条件无法破坏,我们的🔒本身就是来个线程(进程)来产生互斥
  2. 破坏请求与保持条件一次申请所有资源
  3. 破坏不剥夺条件占有部分资源的线程尝试申请其它资源,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件按序来申请资源。

30. 线程安全是什么?线程不安全是什么?

线程安全:多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。(Vector,Hashtable,StringBuffer)

线程不安全:不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。(ArrayList,LinkedList,HashMap,StringBuilder等)

31. 序列化和反序列化

  1. 序列化:就是将对象转化成字节序列的过程。
  2. 反序列化:就是讲字节序列转化成对象的过程。

为什么要序列化

  1. 持久化:对象是存储在JVM中的堆区的,但是如果JVM停止运行了,对象也不存在了。序列化可以将对象转化成字节序列,可以写进硬盘文件中实现持久化。在新开启的JVM中可以读取字节序列进行反序列化成对象。
  2. 网络传输:网络直接传输数据,但是无法直接传输对象,可在传输前序列化,传输完成后反序列化成对象。所以所有可在网络上传输的对象都必须是可序列化的。

如何实现

实现Serializable接口

32. 常用的集合框架中的类与接口

Collection为集合层级的根接口。一个集合代表一组对象,这些对象即为它的元素。Java平台不提供这个接口任何直接的实现。 
Set是一个不能包含重复元素的集合。这个接口对数学集合抽象进行建模,被用来代表集合,就如一副牌。
  常用实现子类:HashSet,TreeSet  
List是一个可以包含重复元素的集合。你可以通过它的索引来访问任何元素。List更像长度动态变换的数组。
  常用实现子类:ArrayList,LinkedList、Vector
Queue: 队列,先进先出 
Map是一个将key映射到value的对象.一个Map不能包含重复的key:每个key最多只能映射一个value。
一些其它的接口有Queue、Dequeue、SortedSet、SortedMap和ListIterator。

33.  ArrayList、LinkedList 、Vector的区别

(1) ArrayList本质是数组 ,线程不同步,ArrayList替代了Vector,查询元素的速度非常快,默认大小10,1.5倍长度扩容

- 频繁的添加操作:可能会造成扩容,内存浪费与大量元素复制移位,随着数据量增多,时空复杂度会增大;

- 频繁的删除操作:可能会出现大量元素的移位

- 频繁的取元素操作:能直接根据数组下标定位元素,因为数据在数组中的物理地址是连续的;

- 【所以:ArrayList 适用于元素的随机访问】

(2) LinkedList 本质是链表,线程不同步,增删元素的速度非常快

- 频繁的添加操作:只需要一个元素内存消耗

- 频繁的删除操作:只需要元素指针重新连接

- 频繁的取元素操作:需要遍历整个链表,因为链表的物理地址是不连续的

- 【所以:LinkedList适用于元素的大量添加与删除】

(3) Vector:底层的数据结构就是数组,线程同步,Vector无论查询和增删。默认大小10,2倍长度扩容。


34.  Collection与Collections的区别?

Collection 是Java集合的根接口,下面有List,Set等子接口

Collections 是集合的工具类,提供了对集合进行排序,查询等方法。

35. 为什么要重写equals和hashcode方法?

(1) 为什么要重写equals方法

Java中的集合(Collection)有两类,一类是List,再有一类是Set。 前者集合内的元素可以重复;后者元素不可重复。

那么我们怎么判断两个元素是否重复呢? 这就是Object.equals方法了。

通常想查找一个集合中是否包含某个对象,就是逐一取出每个元素与要查找的元素进行比较,当发现某个元素与要查找的对象进行equals方法比较的结果相等时,则停止继续查找并返回肯定的信息,否则返回否定的信息。

如果一个集合中有很多元素譬如成千上万的元素,并且没有包含要查找的对象时,则意味着你的程序需要从该集合中取出成千上万个元素进行逐一比较才能得到结论,于是,有人就发明了一种【【哈希算法来提高从集合中查找元素的效率】】,这种方式将集合分成若干个存储区域,每个对象可以计算出一个哈希码,可以将哈希码分组,每组分别对应某个存储区域,根据一个对象的哈希码就可以确定该对象应该存储的那个区域.

(2) 为什么要重写hashCode方法

hashCode方法返回的就是根据对象的内存地址换算出的一个值。这样一来,当集合要添加新的元素时,

先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。

如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了;

如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了,

不相同就散列其它的地址。这样一来实际调用equals方法的次数就大大降低了,几乎只需要一两次

总结:

先通过hashcode方法返回的值,直接定位元素应该存储的物理位置,位置上没有元素则存放,若有元素,再比较对象的equals方法,从而大大减少equals方法调用,提高比较效率。 (面试先用这一句话总结)

36.  HashMap和Hashtable区别

Hashtable<K,V>

底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个Hashtable,效率低,ConcurrentHashMap做了相关优化

初始size为11,扩容:newsize = olesize*2+1

计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length

HashMap<K,V>

底层数组+链表实现+(红黑树),可以存储null键和null值,线程不安全

初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂

扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)

当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀

计算index方法:index = hash & (tab.length – 1)

HashMap概述

HashMap存储的是key-value的键值对,允许key为null,也允许value为null。HashMap内部为数组+链表的结构,会根据key的hashCode值来确定数组的索引(确认放在哪个桶里),如果遇到索引相同的key,桶的大小是2,如果一个key的hashCode是7,一个key的hashCode是3,那么他们就会被分到一个桶中(hash冲突),如果发生hash冲突,HashMap会将同一个桶中的数据以链表的形式存储,但是如果发生hash冲突的概率比较高,就会导致同一个桶中的链表长度过长,遍历效率降低,所以在JDK1.8中如果链表长度到达阀值(默认是8),就会将链表转换成红黑二叉树。

HashMap数据结构


//Node本质上是一个Map.存储着key-value
   static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;             //保存该桶的hash值
        final K key;                //不可变的key
        V value;                    
        Node<K,V> next;      //指向一个数据的指针
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
       }

从源码上可以看到,Node实现了Map.Entry接口,本质上是一个映射(k-v)

刚刚也说过了,有时候两个key的hashCode可能会定位到一个桶中,这时就发生了hash冲突,如果HashMap的hash算法越散列,那么发生hash冲突的概率越低,如果数组越大,那么发生hash冲突的概率也会越低,但是数组越大带来的空间开销越多,但是遍历速度越快,这就要在空间和时间上进行权衡,这就要看看HashMap的扩容机制,在说扩容机制之前先看几个比较重要的字段

//默认桶16个
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认桶最多有2^30个
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//能容纳最多key_value对的个数
 int threshold;
//一共key_value对个数
int size;

threshold=负载因子 * length,也就是说数组长度固定以后, 如果负载因子越大,所能容纳的元素个数越多,如果超过这个值就会进行扩容(默认是扩容为原来的2倍),0.75这个值是权衡过空间和时间得出的,建议大家不要随意修改,如果在一些特殊情况下,比如空间比较多,但要求速度比较快,这时候就可以把扩容因子调小以较少hash冲突的概率。相反就增大扩容因子(这个值可以大于1)。

size就是HashMap中键值对的总个数。还有一个字段是modCount,记录是发生内部结构变化的次数,如果put值,但是put的值是覆盖原有的值,这样是不算内部结构变化的。

因为HashMap扩容每次都是扩容为原来的2倍,所以length总是2的次方,这是非常规的设置,常规设置是把桶的大小设置为素数,因为素数发生hash冲突的概率要小于合数,比如HashTable的默认值设置为11,就是桶的大小为素数的应用(HashTable扩容后不能保证是素数)。HashMap采用这种设置是为了在取模和扩容的时候做出优化。

hashMap是通过key的hashCode的高16位和低16位异或后和桶的数量取模得到索引位置,即key.hashcode()(hashcode>>>16)%length,当length是2n时,h&(length-1)运算等价于h%length,而&操作比%效率更高。而采用高16位和低16位进行异或,也可以让所有的位数都参与越算,使得在length比较小的时候也可以做到尽量的散列。

在扩容的时候,如果length每次是2^n,那么重新计算出来的索引只有两种情况,一种是 old索引+16,另一种是索引不变,所以就不需要每次都重新计算索引。

确定哈希桶数据索引位置

//方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

HashMap的put方法实现

思路如下:

1.table[]是否为空

2.判断table[i]处是否插入过值

3.判断链表长度是否大于8,如果大于就转换为红黑二叉树,并插入树中

4.判断key是否和原有key相同,如果相同就覆盖原有key的value,并返回原有value

5.如果key不相同,就插入一个key,记录结构变化一次

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
//判断table是否为空,如果是空的就创建一个table,并获取他的长度
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//如果计算出来的索引位置之前没有放过数据,就直接放入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
//进入这里说明索引位置已经放入过数据了
            Node<K,V> e; K k;
//判断put的数据和之前的数据是否重复
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))   //key的地址或key的equals()只要有一个相等就认为key重复了,就直接覆盖原来key的value
                e = p;
//判断是否是红黑树,如果是红黑树就直接插入树中
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//如果不是红黑树,就遍历每个节点,判断链表长度是否大于8,如果大于就转换为红黑树
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
//判断索引每个元素的key是否可要插入的key相同,如果相同就直接覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
//如果e不是null,说明没有迭代到最后就跳出了循环,说明链表中有相同的key,因此只需要将value覆盖,并将oldValue返回即可
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
//说明没有key相同,因此要插入一个key-value,并记录内部结构变化次数
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

HashMap的get方法实现

实现思路:

1.判断表或key是否是null,如果是直接返回null

2.判断索引处第一个key与传入key是否相等,如果相等直接返回

3.如果不相等,判断链表是否是红黑二叉树,如果是,直接从树中取值

4.如果不是树,就遍历链表查找

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果表不是空的,并且要查找索引处有值,就判断位于第一个的key是否是要查找的key
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
//如果是,就直接返回
                return first;
//如果不是就判断链表是否是红黑二叉树,如果是,就从树中取值
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果不是树,就遍历链表
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

扩容机制

我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。


因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

HashMap 会问什么?

面试官: 那你跟我讲讲HashMap的内部数据结构?

: 目前我用的是JDK1.8版本的,内部使用数组 + 链表+红黑树;

: 方便我给您画个数据结构图吧:

面试官: 那你清楚HashMap的数据插入原理吗?

: 画个图比较清楚,如下:

  1. 判断数组是否为空,为空进行初始化;
  2. 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;
  3. 查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;
  4. 存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据(onlyIfAbsent为false);
  5. 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;(如果当前节点是树型节点证明当前已经是红黑树了)
  6. 如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8并且数组长度大于64, 大于的话链表转换为红黑树;
  7. 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

面试官: 刚才你提到HashMap的初始化,那HashMap怎么设定初始容量大小的吗?

: 一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16。(补充说明:实现代码如下)

static final int tableSizeFor(int cap) {
  int n = cap - 1;
  n |= n >>> 1;
  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;
  return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
123456789

补充说明:下图是详细过程,算法就是让初始二进制右移1,2,4,8,16位,分别与自己位或,把高位第一个为1的数通过不断右移,把高位为1的后面全变为1,最后再进行+1操作,111111 + 1 = 1000000 = 2 6 2^626 (符合大于50并且是2的整数次幂 )

面试官: 你提到hash函数,你知道HashMap的哈希函数怎么设计的吗?

我: hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。

面试官: 那你知道为什么这么设计吗?

: 这个也叫扰动函数,这么设计有二点原因:

  1. 一定要尽可能降低hash碰撞,越分散越好;
  2. 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

面试官: 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

: 因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为-2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比取余%运算要快。

bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
     return h & (length-1);
}
12345

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部归零,只保留末四位
1234

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。

时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

右移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

结果显示,当HashMap数组长度为512的时候(2 9 2^929),也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

另外Java1.8相比1.7做了调整,1.7做了四次移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。

下面是1.7的hash代码:

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
1234

面试官: 刚刚说到1.8对hash函数做了优化,1.8还有别的优化吗?

: 1.8还有三点主要的优化:

  1. 数组+链表改成了数组+链表+红黑树;
  2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
  3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
  4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

面试官: 你分别跟我讲讲为什么要做这几点优化;

:

  1. 防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
  2. 因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如下图所示:1.7的扩容调用transfer代码,如下所示:
  3. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
void transfer(Entry[] newTable, boolean rehash) {
  int newCapacity = newTable.length;
  for (Entry<K,V> e : table) {
    while(null != e) {
      Entry<K,V> next = e.next;
      if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
      }
      int i = indexFor(e.hash, newCapacity);
      e.next = newTable[i]; //A线程如果执行到这一行挂起,B线程开始进行扩容
      newTable[i] = e;
      e = next;
    }
  }
}
12345678910111213141516


面试官: 那HashMap是线程安全的吗?

: 不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把A线程的数据给覆盖了;还有++size这个地方也会造成多线程同时扩容等问题。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  if ((p = tab[i = (n - 1) & hash]) == null)  //多线程执行到这里
    tab[i] = newNode(hash, key, value, null);
  else {
    Node<K,V> e; K k;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    else if (p instanceof TreeNode) // 这里很重要
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  ++modCount;
  if (++size > threshold) // 多个线程走到这,可能重复resize()
    resize();
  afterNodeInsertion(evict);
  return null;
}
123456789101112131415161718192021222324252627282930313233343536373839404142

面试官: 那你平常怎么解决这个线程不安全的问题?

: Java中有Hashtable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。

Hashtable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

面试官: 那你知道ConcurrentHashMap的分段锁的实现原理吗?

: ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。

面试官: 你前面提到链表转红黑树是链表长度达到阈值,这个阈值是多少?

: 阈值是8,红黑树转链表阈值为6

面试官: 为什么是8,不是16,32甚至是7 ?又为什么红黑树转链表的阈值是6,不是8了呢?

: 因为作者就这么设计的,哦,不对,因为经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,概率说话。。因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化,为了预防这种情况的发生。

面试官: HashMap内部节点是有序的吗?

: 是无序的,根据hash值随机插入

面试官: 那有没有有序的Map?

: LinkedHashMap 和 TreeMap

面试官: 跟我讲讲LinkedHashMap怎么实现有序的?

: LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

/**
 * The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
  * The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
//链接新加入的p节点到链表后端
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
  LinkedHashMap.Entry<K,V> last = tail;
  tail = p;
  if (last == null)
    head = p;
  else {
    p.before = last;
    last.after = p;
  }
}
//LinkedHashMap的节点类
static class Entry<K,V> extends HashMap.Node<K,V> {
  Entry<K,V> before, after;
  Entry(int hash, K key, V value, Node<K,V> next) {
    super(hash, key, value, next);
  }
}
123456789101112131415161718192021222324252627

示例代码:

public static void main(String[] args) {
  Map<String, String> map = new LinkedHashMap<String, String>();
  map.put("1", "安琪拉");
  map.put("2", "的");
  map.put("3", "博客");
  for(Map.Entry<String,String> item: map.entrySet()){
    System.out.println(item.getKey() + ":" + item.getValue());
  }
}
//console输出
1:安琪拉
2:的
3:博客
1234567891011121314

面试官: 跟我讲讲TreeMap怎么实现有序的?

:TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较。

面试官那么为什么默认是16呢?怎么不是4?不是8?

: 关于这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。

太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。

所以,16就作为一个经验值被采用了

面试官: 说说你对红黑树的见解?

  • 每个节点非红即黑
  • 根节点总是黑色的
  • 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
  • 每个叶子节点都是黑色的空节点(NIL节点)
  • 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

面试官: 前面提到通过CAS 和 synchronized结合实现锁粒度的降低,你能给我讲讲CAS 的实现以及synchronized的实现原理吗?

: 下一期咋们再约时间,OK?

: 好吧,回去等通知吧!

Spring--开篇

1. Spring中什么是IoC?

控制反转( Inversion of Control )对软件而言,某一个接口具体实现的选择控制权从调用类中移除,转交给第三方容器决定。 (换而言之,即通过Spring提供的IoC容器,可以将对象之间的依赖关系交由Spring进行控制,避免硬编码所造成的过度程序耦合。)

DI(Dependency Injection) 依赖注入 让调用类对某一接口实现类的依赖关系由第三方容器注入,以移除调用类对某一接口的实现类的依赖。

如项目中controller 依赖于service层对象,不是直接在controller层中 new ServiceImpl(),

而是向Spring容器中获得service层对象(可以根据类型匹配,也可以根据类名匹配获得)

Spring的几种注入bean的方式

在Spring容器中为一个bean配置依赖注入有三种方式:

使用属性的setter方法注入

使用构造器注入

2. Spring的Scope有以下几种,通过@Scope注解来实现:

(1)Singleton:一个Spring容器中只有一个Bean的实例,此为Spring的默认配置,全容器共享一个实例。

(2)Prototype:每次调用新建一个Bean实例。

3. Spring bean生命周期

Bean 的生命周期概括起来就是 4 个阶段:

实例化(Instantiation)--》属性赋值(Populate)--》初始化(Initialization)--》销毁(Destruction)

实例化:第 1 步,实例化一个 bean 对象;

属性赋值:第 2 步,为 bean 设置相关属性和依赖;

初始化:第 3~7 步,步骤较多,其中第 5、6 步为初始化操作,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,该阶段结束,才能被用户使用;

销毁:第 8~10步,第8步不是真正意义上的销毁(还没使用呢),而是先在使用前注册了销毁的相关调用接口,为了后面第9、10步真正销毁 bean 时再执行相应的方法。

4. Spring中常用的注解

@Component:这将 java 类标记为 bean。它是任何 Spring 管理组件的通用构造型。spring 的组件扫描机制现在可以将其拾取并将其拉入应用程序环境中。

@Controller:这将一个类标记为 Spring Web MVC 控制器。标有它的 Bean 会自动导入到 IoC 容器中。

@Service:此注解是组件注解的特化。它不会对 @Component 注解提供任何其他行为。您可以在服务层类中使用 @Service 而不是 @Component,因为它以更好的方式指定了意图。

@Repository:这个注解是具有类似用途和功能的 @Component 注解的特化。它为 DAO 提供了额外的好处。它将 DAO 导入 IoC 容器,并使未经检查的异常有资格转换为 Spring DataAccessException。

@Autowired: 自动装配,默认根据类型匹配

@RestController

@RequestMapping

@GetMapping @PostMapping ...

5. Spring AOP是什么?

AOP的全称是Aspect Oriented Programming,面向切面编程。是对OOP(Object Orient Programming)的一种补充,专门用于处理一些具有横切性质的服务。分离系统中的各种关注点,将核心关注点和横切关注点分离开来。

这样就能减少系统的重复代码,降低模块间的耦合度,并有利于未来的可操作性和可维护性。

什么场合会用AOP?请列举几个

Ø 日志功能 log4j、Ø 安全检查、Ø 事务管理、Ø 缓存

AOP实现机制(原理)是什么?

Spring AOP采用JDK动态代理机制和CGlib字节码生成技术实现。

JDK动态代理:基于接口的代理,一定是基于接口,会生成目标对象的接口类型的子对象。其核心的两个类是InvocationHandler和Proxy。

CGLib代理:基于类的代理,只是它在运行期间生成的代理对象是针对目标类扩展的子类。CGLIB是高效的代码生成包,底层是依靠ASM(开源的java字节码编辑类库)操作字节码实现的,性能比JDK强.

Spring如何实现事务管理的

Spring既支持编程式事务管理(也称编码式事务),也支持声明式的事务管理

声明式事务管理:大多数情况下比编程式事务管理更好用。它将事务管理代码从业务方法中分离出来,以声明的方式来实现事务管理。事务管理作为一种横切关注点,可以通过AOP方法模块化。Spring通过Spring AOP框架支持声明式事务管理

(1)事务的传播特性

事务传播行为就是多个事务方法调用时,如何定义方法间事务的传播。Spring定义了7种传播行为:

(1)propagation_requierd:如果当前没有事务,就新建一个事务,如果已存在一个事务中,加入到这个事务中,这是Spring默认的选择。

(2)propagation_supports:支持当前事务,如果没有当前事务,就以非事务方法执行。

(3)propagation_mandatory:使用当前事务,如果没有当前事务,就抛出异常。

(4)propagation_required_new:新建事务,如果当前存在事务,把当前事务挂起。

(5)propagation_not_supported:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

(6)propagation_never:以非事务方式执行操作,如果当前事务存在则抛出异常。

(7)propagation_nested:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与 propagation_required类似的操作。

SpingBootk--开篇

1. Spring Boot 有哪些优点?

Spring Boot 主要有如下优点:

(1)容易上手,提升开发效率,为 Spring 开发提供一个更快、更广泛的入门体验。
(2)开箱即用,远离繁琐的配置。
(3)提供了一系列大型项目通用的非业务性功能,例如:内嵌Tomcat服务器、安全管理、运行数据监控、运行状况检查和外部化配置等。
(4)没有代码生成,也不需要XML配置。
(5)避免大量的 Maven 导入和各种版本冲突。

2. Spring Boot 的核心注解有哪些

启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:

@SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。

@EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项,如关闭数据源自动配置功能: @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })。

@ComponentScan:Spring组件扫描。

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
      @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
      @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

3. Spring Boot 自动配置原理是什么?

注解 @EnableAutoConfiguration, @Configuration, @ConditionalOnClass 就是自动配置的核心,

@EnableAutoConfiguration 给容器导入META-INF/spring.factories 里定义的自动配置类。

筛选有效的自动配置类。

每一个自动配置类结合对应的 xxxProperties.java 读取配置文件进行自动配置功能

4. 如何理解 Spring Boot 配置加载顺序?

在 Spring Boot 里面,可以使用以下几种方式来加载配置。

1)properties文件;

2)yaml 文件;

3)系统环境变量;

4)命令行参数;

等等……

5. Spring Boot 中的 starter 到底是什么 ?

首先它提供了一个自动化配置类,一般命名为 XXXAutoConfiguration ,在这个配置类中通过条件注解来决定一个配置是否生效(条件注解就是 Spring 中原本就有的),然后它还会提供一系列的默认配置,也允许开发者根据实际情况自定义相关配置,然后通过类型安全的属性注入将这些配置属性注入进来,新注入的属性会代替掉默认属性。正因为如此,很多第三方框架,我们只需要引入依赖就可以直接使用了。当然,开发者也可以自定义 Starter

6. 你使用了哪些 starter maven 依赖项?

使用了下面的一些依赖项

spring-boot-starter-activemq

spring-boot-starter-data-redis

spring-boot-starter-web

spring-boot-starter-aop

spring-boot-starter-cache

这有助于增加更少的依赖关系,并减少版本的冲突

7. Spring Boot启动时都做了什么

  1. SpringBoot在启动的时候从类路径下的META-INF/spring.factories中获取EnableAutoConfiguration指定的值
  2. 将这些值作为自动配置类导入容器 , 自动配置类就生效 , 帮我们进行自动配置工作;
  3. 整个J2EE的整体解决方案和自动配置都在springboot-autoconfigure的jar包中;
  4. 它会给容器中导入非常多的自动配置类 (xxxAutoConfiguration), 就是给容器中导入这个场景需要的所有组件 , 并配置好这些组件 ;
  5. 有了自动配置类 , 免去了我们手动编写配置注入功能组件等的工作

8. Spring Boot 中如何实现定时任务 ?

定时任务也是一个常见的需求,Spring Boot 中对于定时任务的支持主要还是来自 Spring 框架。

在 Spring Boot 中使用定时任务主要有两种不同的方式,一个就是使用 Spring 中的 @Scheduled 注解,另一个则是使用第三方框架 Quartz。

使用 Spring 中的 @Scheduled 的方式主要通过 @Scheduled 注解来实现。

使用 Quartz ,则按照 Quartz 的方式,定义 Job 和 Trigger 即可。

9. 网站大流量高并发访问的处理解决办法

架构层面

1、硬件升级

2、服务器集群、负载均衡、分布式

3、CDN

4、页面静态化

5、缓存技术(Memcache、Redis)

网站本地项目层面

6、数据库优化

1、数据库分表技术

2、数据库读写分离

3、表建立相应的索引

7、禁止盗链

8、控制大文件的上传下载

10.Maven常用构建命令

validate:验证项目是正确的,所有必要的信息都是可用的

compile:编译项目的源代码

test:使用适当的单元测试框架测试编译后的源代码。这些测试不应要求将代码打包或部署

package:使用已编译的代码,并将其打包成可分布格式,例如JAR。

verify:对集成测试的结果进行任何检查,以确保满足质量标准

install:将包安装到本地存储库中,以便在本地其他项目中使用该包

deploy:在构建环境中完成,将最终的包复制到远程存储库中,以便与其他开发人员和项目共享。

11.HTTP中GET POST 区别

GET : 向服务器获得资源,一般用于查询
请求的参数会显示在URL后面
请求参数内容长度有限制
浏览器会缓存请求数据

POST: 修改服务器资源,一般用于添加操作
请求的参数放在HTTP协议的body 部分,比较安全
请求参数内容长度可以很大
浏览器不会缓存请求数据


数据库 MySQL--开篇

1. 事务 transaction

(1)概念

数据库中的事务是指一个任务中有多个SQL语句要执行,这些SQL操作最终要么全部执行成功,要么全部不执行,不会存在部分成功的情况。

(2)属性【重点】 - ACID

(1)原子性(Atomicity):事务操作的是最小的业务逻辑单元,整个过程如原子操作一样,最终要么全部成功,要么全部不执行。

(2)一致性(Consistency):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。

(3)隔离性(Isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

(4)持久性(Durability):事务一旦提交,对数据库中数据的改变就应该是永久性的,不能再回滚。

(3)隔离级别(4个)

读未提交:read uncommitted :事务A和事务B,事务A未提交的数据,事务B可以读取到,出现“脏读”。

读已提交:read committed:事务A和事务B,事务A提交的数据,事务B才能读取到,避免了脏读,但是可能出现不可重复读。          

可重复读:repeatable read:一个事务A内,多次读同一个数据,在这个事务A还没结束时,其他事务不能访问该数据(包括了读写),这样就可以在同一个事务内两次读到的数据是一样的,避免了不可重复读和脏读,但是有时可能会出现幻读;

可序化(串行化):serializable :

事务只能一个接着一个地执行,但不能并发执行,如果仅仅通过“行级锁”是无法实现序列化的,必须通过其他机制保证新插入的数据不会被执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也是最高的,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读

(4)传播特性

PROPAGATION_REQUIRED--支持当前事务,如果当前没有事务,就新建一个事务。这是最常见的选择。

PROPAGATION_SUPPORTS--支持当前事务,如果当前没有事务,就以非事务方式执行。

PROPAGATION_MANDATORY--支持当前事务,如果当前没有事务,就抛出异常。

PROPAGATION_REQUIRES_NEW--新建事务,如果当前存在事务,把当前事务挂起。

PROPAGATION_NOT_SUPPORTED--以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

PROPAGATION_NEVER--以非事务方式执行,如果当前存在事务,则抛出异常。

2. 索引

(1)索引的概念

索引 为了提高查询效率, 是对数据库表中一列或多列的值进行排序的一种结构,如同书的目录,可以加快书籍内容的查询效率

(2)索引的优点

1、索引大大减小了服务器需要扫描的数据量

2、索引可以帮助服务器避免排序和临时表(尽量避免文件排序,而是使用索引排序)

3、索引可以将随机IO变成顺序IO

(3)索引的缺点

创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加

索引需要占物理空间,除了数据表占用数据空间之外,每一个索引还要占用一定的物理空间,如果需要建立聚簇索引,那么需要占用的空间会更大

对表中的数据进行增、删、改的时候,索引也要动态的维护,这就降低了整数的维护速度

如果某个数据列包含许多重复的内容,为它建立索引就没有太大的实际效果。

对于非常小的表,大部分情况下简单的全表扫描更高效;

(4)索引的分类

数据库默认建立的索引是给唯一键建立的,比如主键列就默认成为了索引列

(1) 主键索引(唯一且非空)

(2) 唯一索引(唯一可为空)

(3) 普通索引(普通字段的索引)

(4) 全文索引(一般是varchar,char,text类型建立的,但很少用)

(5) 组合索引(多个字的建立的索引)

(5)哪些列适合作为索引列 ? ( 索引建立的原则)

在经常需要搜索的列上,可以加快搜索的速度

在作为主键的列上,强制该列的唯一性和组织表中数据的排列结构

在经常用在连接(JOIN)的列上,这些列主要是一外键,可以加快连接的速度

在经常需要根据范围(<,<=,=,>,>=,BETWEEN,IN)进行搜索的列上创建索引,因为索引已经排序,其指定的范围是连续的

在经常需要排序(order by)的列上创建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;

在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度

(5)哪些列不该创建索引的列

1、 对于那些在查询中很少使用或者参考的列不应该创建索引。

(若列很少使用到,因此有索引或者无索引,并不能提高查询速度。相反,由于增加了索引,反而降低了系统的维护速度和增大了空间需求。)

3.、对于那些只有很少数据值或者重复值多的列也不应该增加索引。

(这些列的取值很少,例如人事表的性别列,在查询的结果中,结果集的数据行占了表中数据行的很大比例,即需要在表中搜索的数据行的比例很大。增加索引,并不能明显加快检索速度。)

3、 对于那些定义为text, image和bit数据类型的列不应该增加索引。

(这些列的数据量要么相当大,要么取值很少。)

4、 当该列修改性能要求远远高于检索性能时,不应该创建索引。(修改性能和检索性能是互相矛盾的)

(6)MySQL中索引结构(底层的数据结构)

B-TREE 
B+TREE  : MySQL的默认存储引擎InnoDB, 使用的是B+Tree。【重点描述】
HASH 等

<1> B树



B树的特征:

(1)关键字集合分布在整颗树中;

(2)任何一个关键字出现且只出现在一个结点中;

(3)搜索有可能在非叶子结点结束;

(4)其搜索性能等价于在关键字全集内做一次二分查找;

(5)自动层次控制;

<2> B+树【重点】



B+树的特征:

(1)所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;

(2)不可能在非叶子结点命中;

(3)非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;

(4)每一个叶子节点都包含指向下一个叶子节点的指针,从而方便叶子节点的范围遍历。

(5)更适合文件索引系统;

<3>Hash



哈希索引就是采用一定的哈希算法,把键值换算成新的哈希值,检索时不需要类似B+树那样从根节点到叶子节点逐级查找,只需一次哈希算法即可立刻定位到相应的位置,速度非常快。

(7)索引的结构,为什么用B+树?【重点】

1、索引节点没有数据,比较小,能够完全加载到内存中

2、而且叶子节点之间都是链表的结构,所以B+Tree也是【可以支持范围查询】的,而B树每个节点key和data在一起,则无法区间查找

3、B+树中因为数据都在叶子节点,每次查询的【时间复杂度是稳定】的,因此稳定性保证了

理解分析【重点】

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。

B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,B+树只需要去遍历叶子节点就可以实现整棵树的遍历,而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。

(8) B树与B+树对比

1、B+树非叶子节点不存在数据只存索引,B树非叶子节点存储数据

2、B+树查询效率更高。B+树使用双向链表串连所有叶子节点,区间查询效率更高(因为所有数据都在B+树的叶子节点,扫描数据库 只需扫一遍叶子结点就行了),但是B树则需要通过中序遍历才能完成查询范围的查找。

3、B+树查询效率更稳定。B+树每次都必须查询到叶子节点才能找到数据,而B树查询的数据可能不在叶子节点,也可能在,这样就会造成查询的效率的不稳定

4、B+树的磁盘读写代价更小。B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B树更小,通常B+树矮更胖,高度小查询产生的I/O更少。

3. MySQL与Oracle的区别

1. MySQL优点: 体积小、速度快、总体拥有成本低,开源支持多种操作系统,是开源数据库,提供的接口支持多种语言连接操作。

  MySQL缺点:不支持热备份, 最大的缺点是其安全系统,主要是复杂而非标准,另外只有到调用mysqladmin来重读用户权限时才发生改变,没有一种存储过程语言,这是对习惯于企业级数据库的程序员的最大限制。    

2. SqlServer优点:易用性、适合分布式组织的可伸缩性、用于决策支持的数据仓库功能、与许多其他服务器软件紧密关联的集成性、良好的性价比等。

  SqlServer缺点:

开放性 :SQL Server 只能windows上运行,没有丝毫开放性操作系统系统稳定 。

安全性:没有获得任何安全证书。  

3. Oracle 优点

(1)开放性好:所有主流平台上运行(包括 windows)完全支持;

(2)可伸缩性,并行性:oracle 有并行服务器提供高用性和高伸缩性簇解决方;

(3)安全性高:获得最高认证级别的ISO标准认证。

Oracle缺点:

(1)对硬件的要求很高;

(2)价格比较昂贵;

(3)管理维护麻烦一些;

(4)操作比较复杂,需要技术含量较高。

4. 怎么写出高性能SQL语句

1、添加索引 ,索引是可以帮助快速高效查询数据的一种数据结构,优先考虑where、group by使用到的字段

2、分表分库技术(取模分表=水平分割,垂直分割)  

(1)什么时候分库?

电商项目将一个项目进行分割,拆成多个小项目,每个小项目有自己单独的数据库,互不影响----垂直分割 会员数据库,订单数据库,支付数据库

(2)什么时候分表?

分表 根据业务需求,比如存放日志(按年分表存放)

水平分割(取模算法)用于均匀的分表...

3. 尽量避免使用select *,返回无用的字段会降低查询效率。

4. 尽量避免使用in 和not in,会导致数据库引擎放弃索引进行全表扫描。

5. 尽量避免使用or,会导致数据库引擎放弃索引进行全表扫描

6. 尽量避免在字段开头模糊查询,会导致数据库引擎放弃索引进行全表扫描。

7. 尽量避免进行null值的判断,会导致数据库引擎放弃索引进行全表扫描。

8. 尽量避免在where条件中等号的左侧进行表达式、函数操作,会导致数据库引擎放弃索引进行全表扫描

5.  三大范式

在设计表 的时候,需要遵循---范式Normal Form
第一范式(1NF):根据业务需求,该列分割到不可再分割 的列,具有原子性
第二范式(2NF):先满足第一范式,确保表中的每列都和主键相关
第三范式(3NF):先满足第一范式和第二范式,确保表中的每列直接依赖于主键列,而不是间接依赖关系

6. MySQL的锁机制

(1)锁机制起步

锁是计算机用以协调多个进程间并发访问同一共享资源的一种机制。MySQL中为了保证数据访问的一致性与有效性等功能,实现了锁机制,MySQL中的锁是在服务器层或者存储引擎层实现的。

(2)行锁与表锁

InnoDB既支持行锁,也支持表锁

行锁是作用在索引上的,

当没有查询列没有索引时,InnoDB就不会去搞什么行锁了,毕竟行锁一定要有索引,所以它现在搞表锁,把整张表给锁住了

补充:  InnoDB中的聚簇索引

每一个InnoDB表都需要一个聚簇索引,有且只有一个

(1) 若给表定义一个主键,那么MySQL将使用主键作为聚簇索引

(2) 若不为定义一个主键,那么MySQL将会把第一个唯一索引(而且要求NOT NULL)作为聚簇索引

(3) 若以上都没有,MySQL将自动创建一个名字为GEN_CLUST_INDEX的隐藏聚簇索引

两种锁的比较

表锁:加锁过程的开销小,加锁的速度快;不会出现死锁的情况;锁定的粒度大,发生锁冲突的几率大,并发度低;

  • 一般在执行DDL语句时会对整个表进行加锁,比如说 ALTER TABLE 等操作;
  • 如果对InnoDB的表使用行锁,被锁定字段不是主键,也没有针对它建立索引的话,那么将会锁整张表;
  • 表级锁更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用,如Web 应用。

行锁:加锁过程的开销大,加锁的速度慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高;

  • 最大程度的支持并发,同时也带来了最大的锁开销。
  • 在 InnoDB 中,除单个 SQL 组成的事务外,锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。
  • 行级锁只在存储引擎层实现,而 MySQL 服务器层没有实现。 行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。

(3) InnoDB锁模式

<1> InnoDB中的行锁

    InnoDB实现了以下两种类型的行锁:

共享锁(S):加了锁的记录,所有事务都能去读取但不能修改,同时阻止其他事务获得相同数据集的排他锁;

排他锁(X):允许已经获得排他锁的事务去更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁;

<2> InnoDB表锁——意向锁

意向锁是表级锁,分为读意向锁(IS锁)和写意向锁(IX锁)。当事务要在记录上加上行锁时,要首先在表上加上意向锁。这样判断表中是否有记录正在加锁就很简单了,只要看下表上是否有意向锁就行了,从而就能提高效率。

意向锁之间是不会产生冲突的,它只会阻塞表级读锁或写锁。意向锁不于行级锁发生冲突

7. MySQL存储引擎

存储引擎提供存储机制、索引技巧、锁定水平等功能,使用不同的存储引擎还可以获得特定的功能。

MySQL 常用的存储引擎: MyISAM, InnoDB

MyISAM

不支持行锁(MyISAM只有表锁),读取时对需要读到的所有表加锁,写入时则对表加排他锁;

不支持事务

不支持外键

不支持崩溃后的安全恢复

在表有读取查询的同时,支持往表中插入新纪录

支持BLOB和TEXT的前500个字符索引,支持全文索引

支持延迟更新索引,极大地提升了写入性能

对于不会进行修改的表,支持 压缩表 ,极大地减少了磁盘空间的占用

其中:

Mysql的行锁和表锁( 锁是计算机协调多个进程或纯线程并发访问某一资源的机制)

表级锁: 每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;

行级锁: 每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高;

InnoDB

支持行锁,采用MVCC来支持高并发,有可能死锁

支持事务

支持外键

支持崩溃后的安全恢复

不支持全文索引

二者的常见对比

1、count运算上的区别: 因为MyISAM缓存有表meta-data(行数等),因此在做COUNT(*)时对于一个结构很好的查询是不需要消耗多少资源的。而对于InnoDB来说,则没有这种缓存。

2、是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行数度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持事务,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。

3、是否支持外键: MyISAM不支持,而InnoDB支持。

总之:MyISAM更适合读密集的表,而InnoDB更适合写密集的的表。一般来说,如果需要事务支持,并且有较高的并发读取频率(MyISAM的表锁的粒度太大,所以当该表写并发量较高时,要等待的查询就会很多了),InnoDB是不错的选择。如果你的数据量很大(MyISAM支持压缩特性可以减少磁盘的空间占用),而且不需要支持事务MyISAM是最好的选择

9. SQL

(1)DDL 建表:学生表、课程表、成绩表

(2)DML 常用查询

数据库连接池

第一、连接池的建立。一般在系统初始化时,连接池会根据系统配置建立,并在池中创建了几个连接对象,以便使用时能从连接池中获取。
例如Vector、Stack等。
第二、连接池的管理。连接池管理策略是连接池机制的核心,当客户请求数据库连接时,首先查看连接池中是否有空闲连接,如果存在空闲连接,则将连接分配给客户使用;如果没有空闲连接,
则查看当前所开的连接数是否已经达到最大连接数,如果没达到就重新创建一个连接给请求的客户;如果达到就按设定的最大等待时间进行等待,
如果超出最大等待时间,则抛出异常给客户。 当客户释放数据库连接时,先判断该连接的引用次数是否超过了规定值,
如果超过就从连接池中删除该连接,否则保留为其他客户服务。该策略保证了数据库连接的有效复用,避免频繁的建立、
释放连接所带来的系统资源开销。
第三、连接池的关闭。当应用程序退出时,关闭连接池中所有的连接,释放连接池相关的资源,该过程正好与创建相反

Redis--开篇

1. Redis 为何这么快?

Redis 是 C 语言开发的一个开源的高性能键值对(key-value)的【内存数据库】,可以用作数据库、缓存、消息中间件等。它是一种 NoSQL(not-only sql非关系型数据库)的数据库

1)基于内存的读写,10w/s 的QPS;绝大部分请求是纯粹的内存操作,非常快速,跟传统的磁盘文件数据存储相比,避免了通过磁盘IO读取到内存这部分的开销。

2)单线程减少上下文切换,同时保证原子性;

3)IO多路复用,可以处理并发的链接

4)支持丰富数据类型,支持string,list,set,sorted set,hash

2. Redis 的数据类型?

包括String,List,Set,Zset,Hash

3. Redis 是单进程单线程的 ?

Redis 是单进程单线程的, redis 利用队列技术将并发访问变为串行访问, 消除了传统数据库串行控制的开销。

4. 一个字符串类型的值能存储最大容量是多少?

512M

5. Redis雪崩、穿透、击穿

(1)Redis雪崩



概念:

雪崩就是指缓存中大批量热点数据过期后系统涌入大量查询请求,因为大部分数据在Redis层已经失效,请求渗透到数据库层,大批量请求犹如洪水一般涌入,引起数据库压力造成查询堵塞甚至宕机。

解决方案

(1)  缓存雪崩的事前事中事后的解决方案1

【Redis集群高可用】

事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。

  即分布式集群,其中一台Redis服务器挂掉,其他Redis主机再服务,实现高可用

【多缓存、限流、熔断机制】    

事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。

   即先查询本地ehcache 缓存 ----->查询Redis -----> MySQL

   其中再实现熔断器限流&降级

【缓存预热】    

事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

好处:数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。 - 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。 - 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。

(2) 解决方案2 【加锁】

用加锁或者队列的方式保证来不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层数据库。

(3) 解决方案3 【过期时间均匀分布】

将缓存失效时间分散开,比如每个key的过期时间是随机,防止同一时间大量数据过期现象发生,这样不会出现同一时间全部请求都落在数据库层,如果缓存数据库是分布式部署,将热点数据均匀分布在不同Redis和数据库中,有效分担压力,别让一个人扛。

(4)解决方案4 【永不过期】

简单粗暴,让Redis数据永不过期(如果业务准许,比如不用更新的名单类)。当然,如果业务数据准许的情况下可以,比如中奖名单用户,每期用户开奖后,名单不可能会变了,无需更新。

(2)Redis穿透


概念

对于系统A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。那 4000 个请求数据,都不存在于缓存中,所以需要每次去数据库里查,可能也查不到。这样Redis就失效,不发挥作用了。也导致数据库直接面临大量请求。如数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

解决方案

1、IP恶意频繁访问进行拦截

2、请求参数进行过滤

3、将不存在于缓存、数据库的数据,设置为null , 保存到redis 缓存中,并且设置过期时间 (redis : -1-null, -2- null )保证在短期内 用户恶意的请求 从redis 缓存中加载数据

(3)Redis击穿

概念

缓存击穿:某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,

当这个 key 在【失效的瞬间】,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

解决方案

可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。

使用互斥锁(mutex key)

简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。

总结:

雪崩: 【大批量热点数据过期】

1、数据的过期时间采用随机数,均匀分布

2、数据的过期时间设置为永不过期

3、搭建redis集群,实现高可用

4、本地ehcache + redis + 熔断&降级

5、定期加载热数据到Redis缓存

穿透:【恶意请求,数据不存在于缓存、数据库中,如 id = -1,-2】

1、IP恶意频繁访问进行拦截

2、请求参数进行过滤

3、将不存在于缓存、数据库的数据,设置为null , 保存到redis 缓存中,并且设置过期时间 (redis : -1-null,  -2- null )保证在短期内 用户恶意的请求 从redis 缓存中加载数据

击穿:【 单个热点数据 key 在 失效的瞬间 】

1、热点数据设置为永远不过期

2、加互斥锁 --- mysql --- redis

6. Redis 缓存淘汰策略

在Redis中,内存的大小是有限的,所以为了防止内存饱和,需要实现某种键淘汰策略。主要有两种方法,一种是当Redis内存不足时所采用的内存释放策略。另一种是对过期key进行删除的策略,也可以在某种程度上释放内存。

(1)有设置过期时间数据

过期时间快到的数据,优先删除

volatile-lru : 最近最少被使用,优先删除

volatile-lfu : 使用次数最少的,优先删除

volatile-random : 随机删除

(2) 全部所有数据

LRU : 最近最少被使用,优先删除

LFU : 使用次数最少的,优先删除

Random : 随机删除

(3) 默认值:不删除

7. Redis与数据库一致性方案


存在的问题:Redis 缓存的数据值  ≠  MySQL数据库中的值

第 1 种:延时双删策略   【不推荐】

1、先进行缓存清除,    // redis.del(key);

2、再执行update数据库 //db.update(data);

3、最后(延迟N秒)    //Thread.sleep(100);

4、再执行删除缓存。   //redis.del(key);

优点: 操作比较简单,一定程度可以保证缓存和db 数据一致性;

缺点: 在休眠时间内数据存在不一致,而且又增加了写请求的耗时。

第 2 种:基于binlog异步更新

逻辑思路:启动Canal客户端获取binlog日志详情信息–> 逻辑判断–>缓存

以mysql为例 可以使用阿里的canal

(1)canal将binlog日志采集发送到MQ队列里面

(2) 然后编写一个简单的缓存删除消息者订阅binlog日志

(3) 根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性

8. Redis分布式锁

(1)什么是分布式锁

分布式锁,即分布式系统中的锁。在单体应用中我们通过锁解决的是控制共享资源访问的问题,而分布式锁,就是解决了分布式系统中控制共享资源访问的问题。与单体应用不同的是,分布式系统中竞争共享资源的最小粒度从线程升级成了进程

(2)分布式锁的实现方式

  • 基于数据库实现分布式锁
  • 基于Zookeeper实现分布式锁
  • 基于reids实现分布式锁

mysql ----> 行锁 + 乐观锁。获取锁:select update ;执行业务;释放锁:本地事物提交。

Zookeeper -----> 公平锁。获取锁:创建节点(临时顺序);执行业务 ;释放锁:关闭连接,临时顺序节点自动删除。

redis --------> 加锁 setnx orderlock A expire 设置过期时间; 释放锁:del orderlock。

Redis分布式锁实现步骤:

//1. 获取锁 - setnx lock uuid EX 3000 NX
Boolean flag = redisTemplate.opsForValue().setIfAbsent("lock",uuid,3, TimeUnit.SECONDS);
// 中间写业务....
//最后使用Lua脚本释放锁,保证释放锁操作的原子性
 Long execute = (Long) redisTemplate.execute(script, Arrays.asList(key),uuid);

分析:【重点】

1、 所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 `setnx key value`,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

2、在使用 `set key value nx px xxx` 命令时,value 最好是随机字符串UUID,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(套娃)

3、尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁.... ,因此需要配合 Lua 脚本释放锁。

9. Redis持久化机制

(1)什么是持久化机制

Redis是一个基于内存的数据库,所有的数据都存放在内存中,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制

Redis的持久化机制有两种,第一种是RDB快照,第二种是AOF日志

(2) RDB持久化

RDB持久化是指在指定的时间间隔内将内存中的数据集以快照的方式写入磁盘,并保存到一个名为dump.rdb的二进制文件中,也是默认的持久化方式,它恢复时是将快照文件从磁盘直接读到内存里。

(3)AOF持久化

Redis执行过的每个写操作以日志的形式记录下来(读操作不记录),只许追加文件但不可以改写文件(appendonly.aof文件)。redis启动的时候会读取该文件进行数据恢复,根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。

(4)RDB快照和AOF日志的区别

RDB快照是一次全量备份,AOF是连续的增量备份。

RDB快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本  。

RDB------>内存中数据集的快照,默认开启,恢复速度快,数据容易丢失

AOF------>操作日志,默认关闭,恢复速度慢,安全

SpringCloud(Alibaba)--开篇

1. 什么是微服务?

微服务是一种分布式系统架构风格,它的核心理念是将传统的单一应用开发为一组微型服务,每个服务运行在独立的进程中,服务之间采用轻量级通信机制进行相互调用。

传统单体应用存在的弊端:

  • 所有的模块全都耦合在一块,代码量大,维护困难
  • 都共用一个数据库,存储方式比较单一
  • 所有的模块开发所使用的技术一样

微服务优势:微服务的目的是有效的拆分应用,实现敏捷开发和部署

  • 每个模块就相当于一个单独的项目,代码量明显减少,遇到问题也相对来说比较好解决。
  • 每个模块都可以使用不同的存储方式(比如有的用redis,有的用mysql等),数据库也是单个模块对应自己的数据库。
  • 每个模块都可以使用不同的开发技术,开发模式更灵活

2. 什么是Spring Cloud&&Alibaba?

Spring Cloud是Spring开源组织下的一个子项目,提供了一系列用于实现分布式微服务系统的工具集,帮助开发者快速构建微服务应用。

Spring Cloud Alibaba是Spring Cloud的子项目;包含微服务开发必备组件;基于和符合Spring Cloud标准的阿里的微服务解决方案。

3.Nacos

英文全称Dynamic Naming and Configuration Service,Na为naming/nameServer即注册中心,co为configuration即注册中心,service是指该注册/配置中心都是以服务为核心

(1)注册中心

服务提供者、服务消费者、服务发现组件这三者之间的关系大致如下

1、微服务在启动时,将自己的网络地址等信息注册到服务发现组件(nacos server)中,服务发现组件会存储这些信息。

2、各个微服务与服务发现组件使用一定机制通信(例如在一定的时间内发送心跳包)。服务发现组件若发现与某微服务实例通信正常则保持注册状态(up在线状态)、若长时间无法与某微服务实例通信,就会自动注销(即:删除)该实例。

3、服务消费者可从服务发现组件查询服务提供者的网络地址,并使用该地址调用服务提供者的接口。

4、当微服务网络地址发生变更(例如实例增减或者IP端口发生变化等)时,会重新注册到服务发现组件。

4.Feign

Feign是一个http请求调用的轻量级框架,可以以Java接口注解的方式调用Http请求。Spring Cloud引入 Feign并且集成了Ribbon实现客户端负载均衡调

Feign远程调用,核心就是通过一系列的封装和处理,将以JAVA注解的方式定义的远程调用API接口,最终转换成HTTP的请求形式,然后将HTTP的请求的响应结果,解码成JAVA Bean,放回给调用者。

@FeignClient(value="article-server", path = "/article")
public interface IFeignArticleController {
    /**
     * Feign接口-根据标签ids查询对应的标签信息
     * @return
     */   
    @GetMapping("/api/feign/label/list/{ids}")
    List<Label> getLabelListById(@PathVariable("ids") List<String> labelIds)

5. Gateway服务网关

什么是服务网关
网关是整个微服务API请求的入口,负责拦截所有请求,再分发到服务上去。
可以实现日志拦截、权限控制、解决跨域、限流、熔断、负载均衡,隐藏服务端的ip,黑名单与白名单拦截、授权等。

6.服务容错-Sentinel

(1)  什么是雪崩效应?

1、将业务拆分为不同服务,服务与服务之间可以相互调用,但是由于网络原因或者自身的原因,服务并不能保证服务的100%可用,如果单个服务出现问题,调用这个服务就会出现网络延迟,此时若有大量的网络涌入,会形成任务堆积,最终导致服务瘫痪。

2、在分布式系统中,由于网络原因或自身的原因,服务一般无法保证 100% 可用。如果一个服务出现了问题,调用这个服务就会出现线程阻塞的情况,此时若有大量的请求涌入,就会出现多条线程阻塞等待(在java程序里面,一次请求对应线程对应的是服务器资源–>CPU、内存),进而导致服务瘫痪。

3、由于服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果,这就是服务故障的 “雪崩效应” 。

(2) Sentinel 作用

sentinel是轻量级的流量控制、熔断降级Java库(容错的库)

(3) 常见容错方案【避免雪崩效应】

超时模式–限流模式–仓壁模式–断路器模式–降级模式

超时【思想:只要释放够快服务就不容易那么死了】
为每次请求设置一个最大响应时间(超时时间,如1秒),如果超过这个时间,不管这次请求是否成功,就断开这次请求,释放掉线程。只要线程释放速度够快,被请求的服务就不那么容易被拖死。

限流【思想:只有一碗的饭量,给再多也只是吃一碗】
高并发的系统存在大量的线程阻塞,若经过评估被请求B服务的实例,最大能够承载的QPS是1000,那么久可以为B服务设置一个适合的限流的值(如800QPS),只要某一个实例达到设置的阈值,再有流量(请求)进来就会被直接拒绝。实现了自己的保护

仓壁/隔离模式【思想:不把鸡蛋放一个篮子里面,各有各的线程池】
如将船分为若干个船舱,船舱与船舱之间用钢板焊死,使船舱达到隔离,即使某个船舱进水也不会影响其它的船舱。
常见的隔离方式有:线程池隔离和信号量隔离。
线程池隔离:如为A、B两个controller设置独立的线程池(coreSize=10),他们之间就是用线程池这个钢板焊死了,而A controller对应的API调不通就像是船舱进水了,A的船舱进水与B的船舱没有任何关系。这就是仓壁模式。

断路器模式【监控+开关】
(1)全开状态【服务调用失败达到一定次数】
一定时间内,服务调用失败达到一定次数,且多次检测无恢复迹象,断路器全开
(2)半开状态【服务有恢复迹象】
短时间内,有恢复迹象,断路器会将部分请求发给该服务,断路器半开
(3)关闭状态【服务正常状态】
当服务处于正常状态,能正常调用,断路器关闭

降级
降级其实就是为服务提供一个托底方案,一旦服务无法正常调用,就使用托底方案。

底层开始篇-GC

1.什么是垃圾回收?

   垃圾回收(Garbage Collection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。

注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。但不能说是回收对象,可以理解为一种文字游戏。

分析:

  引用:如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。(引用都有哪些?对垃圾回收又有什么影响?

  垃圾:无任何对象引用的对象(怎么通过算法找到这些对象呢?)。

  回收:清理“垃圾”占用的内存空间而非对象本身(怎么通过算法实现回收呢?)。

  发生地点:一般发生在堆内存中,因为大部分的对象都储存在堆内存中(堆内存为了配合垃圾回收有什么不同区域划分,各区域有什么不同?)。

  发生时间:程序空闲时间不定时回收(回收的执行机制是什么?是否可以通过显示调用函数的方式来确定的进行回收过程?

   带着这些问题我们开始进一步的分析。

2.Java中的对象引用

(1)强引用(Strong Reference):如“Object obj = new Object()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。

(2)软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。

(3)弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。

(4)虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。

3.判断对象是否是垃圾的算法。

     Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:(1)找到所有存活对象;(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。

引用计数算法(Reference Counting Collector)

堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。   
优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。  
缺点: 难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。    早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。

根搜索算法(Tracing Collector)

首先了解一个概念:***根集(Root Set)***
    所谓根集(Root Set)就是正在执行的Java程序可以访问的引用变量(注意:不是对象)的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。
s1.study();
s1.name 
    这种算法的基本思路:
 (1)通过一系列名为“GC Roots”的对象作为起始点,寻找对应的引用节点。
 (2)找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。
 (3)重复(2)。
 (4)搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。
    Java和C#中都是采用根搜索算法来判定对象是否存活的。
**标记可达对象:**
    JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。 
Book book1 = new Book();
fun(book1)
public static  void fun(Book book2){
  book2.setName("....")
}


Java的堆内存(Java Heap Memory)

Java的堆内存基于Generation算法(Generational Collector)划分为新生代、年老代和持久代。新生代又被进一步划分为Eden和Survivor区,最后Survivor由FromSpace(Survivor0)和ToSpace(Survivor1)组成。所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。
 分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的代采取不同的回收算法 进行垃圾回收(GC),以便提高回收效率。

Java的内存空间除了堆内存还有其他部分:

1)栈

   每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、参数和中间结果。

2)本地方法栈

   用于支持native方法的执行,存储了每个native方法调用的状态。

4)方法区

   存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(PermanetGeneration)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。

堆内存分配区域:

1.年轻代(Young Generation)

几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0,Survivor1)区。大部分对象在Eden区中生成。当新对象生成,Eden Space申请失败(因为空间不足等),则会发起一次GC(Scavenge GC)。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了时,则将Eden区和Survivor0区存活对象复制到另一个Survivor1区,然后清空Eden和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空, 如此往复。当Survivor1区不足以存放 Eden和Survivor0的存活对象时,就将存活对象直接存放到老年代。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。

2.年老代(Old Generation)

 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。比如:

     byte[] data = new byte[4*1024*1024]

     这种一般会直接在老年代分配存储空间。

     当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。

3.持久代(Permanent Generation)

用于存放静态文件(class类、方法)和常量等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

     永久代空间在Java SE8特性中已经被移除。取而代之的是元空间(MetaSpace)。因此不会再出现“java.lang.OutOfMemoryError: PermGen error”错误。

4.垃圾回收执行时间和注意事项

 GC分为Scavenge GC和Full GC。

Scavenge GC :发生在Eden区的垃圾回收。

Full GC :对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。

   有如下原因可能导致Full GC:

   1.年老代(Tenured)被写满;

   2.持久代(Perm)被写满;

   3.System.gc()被显示调用;

   4.上一次GC之后Heap的各域分配策略动态变化.

5.与垃圾回收时间有关的两个函数

System.gc()方法

使用System.gc()可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求Java的垃圾回收。在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式如下:    java -verbosegc classfile    需要注意的是,调用System.gc()也仅仅是一个请求(建议)。JVM接受这个消息后,并不是立即做垃圾回收,而只是对几个垃圾回收算法做了加权,使垃圾回收操作容易发生,或提早发生,或回收较多而已。

finalize()方法

 概述:在JVM垃圾回收器收集一个对象之前,一般要求程序调用适当的方法释放资源。但在没有明确释放资源的情况下,Java提供了缺省机制来终止该对象以释放资源,这个方法就是finalize()。它的原型为:

protected void finalize() throws Throwable

在finalize()方法返回之后,对象消失,垃圾收集开始执行。原型中的throws Throwable表示它可以抛出任何类型的异常

触发主GC的条件

  1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。

   2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。

 3)在编译过程中作为一种优化技术,Java 编译器能选择给实例赋 null 值,从而标记实例为可回收。

由于是否进行主GC由JVM根据系统环境决定,而系统环境在不断的变化当中,所以主GC的运行具有不确定性,无法预计它何时必然出现,但可以确定的是对一个长期运行的应用来说,其主GC是反复进行的。

减少GC开销的措施

(1)不要显式调用System.gc()

此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

(2)尽量减少临时对象的使用    

临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

(3)对象不用时最好显式置为Null    

一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

(4)尽量使用StringBuilder,而不用String来累加字符串

(5)能用基本类型如int,long,就不用Integer,Long对象    

基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

(6)尽量少用静态对象变量static  

静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

GC性能调优

    1、 Java虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序(尤其服务器端)的性能和稳定性有着非常重要的影响。性能调优需要具体情况具体分析,而且实际分析时可能需要考虑的方面很多,这里仅就一些简单常用的情况作简要介绍。

    2、 我们可以通过给Java虚拟机分配超大堆(前提是物理机的内存足够大)来提升服务器的响应速度,但分配超大堆的前提是有把握把应用程序的Full GC频率控制得足够低,因为一次Full GC的时间造成比较长时间的停顿。控制Full GC频率的关键是保证应用中绝大多数对象的生存周期不应太长,尤其不能产生批量的、生命周期长的大对象,这样才能保证老年代的稳定。

    3、 Direct Memory在堆内存外分配,而且二者均受限于物理机内存,且成负相关关系。因此分配超大堆时,如果用到了NIO机制分配使用了很多的Direct Memory,则有可能导致Direct Memory的OutOfMemoryError异常,这时可以通过-XX:MaxDirectMemorySize参数调整Direct Memory的大小。

    4、 除了Java堆和永久代以及直接内存外,还要注意下面这些区域也会占用较多的内存,这些内存的总和会受到操作系统进程最大内存的限制:1、线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(纵向无法分配,即无法分配新的栈帧)或OutOfMemoryError(横向无法分配,即无法建立新的线程)。

     5、Socket缓冲区:每个Socket连接都有Receive和Send两个缓冲区,分别占用大约37KB和25KB的内存。如果无法分配,可能会抛出IOException:Too many open files异常。关于Socket缓冲区的详细介绍参见我的Java网络编程系列中深入剖析Socket的几篇文章。

     JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中。

     虚拟机和GC:虚拟机和GC的代码执行也要消耗一定的内存。

底层开篇-JVM

1. 什么是JVM?

1、JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

2、Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

2. JRE/JDK/JVM是什么关系?

1、JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

2、JDK(Java Development Kit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

3、JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

3. JVM原理

JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。


java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

4. JVM执行程序的过程

  1. 加载.class文件----》 2) 管理并分配内存 -----》3) 执行垃圾收集

JRE(java运行时环境)由JVM构造的java程序的运行环,也是Java程序运行的环境,但是他同时一个操作系统的一个应用程序一个进程,因此他也有他自己的运行的生命周期,也有自己的代码和数据空间。JVM在整个jdk中处于最底层,负责于操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境,因此也就虚拟计算机。

操作系统装入JVM是通过jdk中Java.exe来完成,通过下面4步来完成JVM环境:

1) 创建JVM装载环境和配置

2) 装载JVM.dll

3) 初始化JVM.dll并挂界到JNIENV(JNI调用接口)实例

4) 调用JNIEnv实例装载并处理class类。

5. JVM的生命周期

  1. JVM实例对应了一个独立运行的java程序它是进程级别

a) 启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void
main(String[] args)函数的class都可以作为JVM实例运行的起点

b) 运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以表明自己创建的线程是守护线程
c) 消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出

  1. JVM执行引擎实例则对应了属于用户运行程序的线程它是线程级别的

6. JVM的体系结构

  • 类装载器(ClassLoader)(用来装载.class文件)
  • 执行引擎(执行字节码,或者执行本地方法)
  • 运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)

7. JVM运行时数据区

第一块:PC寄存器

PC寄存器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则PC寄存器中不存储任何信息。

第二块:JVM栈

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

int a = 10;  // 栈:a, 10

stu1 = new Student();// 栈: stu1  ; 堆: new Student()对象

int[] a1 = {10,20,30,40}; // 栈: a1  ;  堆: 数组的值

char[] xx = new char[16];

第三块:堆(Heap)

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

(1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

(2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

(3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

(4) 所有新创建的Object 都将会存储在新生代Yong Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。

第四块:方法区域(Method Area)

(1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

(2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

第五块:运行时常量池(Runtime Constant Pool)

存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

第六块:本地方法堆栈(Native Method Stacks)

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

JVM参数设置



-Xms设置堆的最小空间大小。
-Xmx设置堆的最大空间大小。
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小


没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制。

老年代空间大小=堆空间大小-年轻代大空间大小

VM内存区域总体分两类,heap区 和 非heap 区 。

heap区: 堆区分为Young Gen(新生代),Tenured Gen(老年代-养老区)。其中新生代又分为Eden Space(伊甸园)、Survivor Space(幸存者区)。
非heap区: Code Cache(代码缓存区)、Perm Gen(永久代)、Jvm Stack(java虚拟机栈)、Local Method Statck(本地方法栈)。

(1)为什么要区分新生代和老生代?

堆中区分的新生代和老年代是为了垃圾回收,新生代中的对象存活期一般不长,
而老年代中的对象存活期较长,所以当垃圾回收器回收内存时,新生代中垃圾回收效果较好,
会回收大量的内存,而老年代中回收效果较差,内存回收不会太多。

(2)不同代采用的算法区别?

基于以上特性,新生代中一般采用复制算法,因为存活下来的对象是少数,
所需要复制的对象少,而老年代对象存活多,不适合采用复制算法,
一般是标记整理和标记清除算法。
因为复制算法需要留出一块单独的内存空间来以备垃圾回收时复制对象使用,
所以将新生代分为eden区和两个survivor区,每次使用eden和一个survivor区,
另一个survivor作为备用的对象复制内存区。

请看下面 :

对于jvm内存配置参数:

-Xmx10240m -Xms10240m -Xmn5120m -XXSurvivorRatio=3

其最小内存值和Survior区总大小分别是: 10240m,2048m

我们只需要知道Survior区有两个,就是图中的S0和S1,
而Eden区只用一个, -XXSurvivorRatio参数是Eden区和单个Survior区的比例,
所以应该有(3+1+1)*Survior=5012m,图中问的是Survior总大小(需乘2)

(3)修改JVM参数

<1>IDEA 可以在 Run-edit configration.. 中设置参数,如下所示

<2>JetBrains\IntelliJ IDEA 2021.1.1\bin\idea64.exe.vmoptions

-Xms128m
-Xmx750m
-XX:ReservedCodeCacheSize=512m
-XX:+UseG1GC
-XX:SoftRefLRUPolicyMSPerMB=50
-XX:CICompilerCount=2
-XX:+HeapDumpOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow
-ea
-Dsun.io.useCanonCaches=false
-Djdk.http.auth.tunneling.disabledSchemes=""
-Djdk.attach.allowAttachSelf=true
-Djdk.module.illegalAccess.silent=true
-Dkotlinx.coroutines.debug=off

synchronized底层实现原理

synchronized关键字的作用

synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

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

三种使用方式:

(1)修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

(2)修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

(3)修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

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

1. 为什么说synchronized是一个重量级锁

synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。

2. synchronized底层实现原理

同步方法通过ACC_SYNCHRONIZED 关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。

同步代码块通过monitorenter和monitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。每个对象自身维护着一个被加锁次数的计数器,当计数器不为0时,只有获得锁的线程才能再次获得锁。

public class MyOrder {
    // ACC_SYNCHRONIZED  隐式监视器锁
    public synchronized void fun1(){
        System.out.println("fun1111111111111");
    }
    // 显示监视器锁 monitorenter , monitorexit
    public  void fun2(){
        synchronized (this){
            System.out.println("fun22222222222");
        }
    }
}

(1)同步代码块

反编译:

原理:

基于对象的监视器(ObjectMonitor),在字节码文件里面可以看到,在同步方法执行前后,有两个指令,进入同步方法前monitorenter,方法执行完成后monitorexit;

对象都有一个监视器ObjectMonitor,这个监视器内部有很多属性,比如当前等待线程数、计数器、当前所属线程等;其中计数器属性就是用来记录是否已被线程占有,方法执行到monitorenter时,计数器+1,执行到monitorexit时,计数器-1,线程就是通过这个计数器来判断当前锁对象是否已被占用(0为未占用,此时可以获取锁);一个synchronize锁会有两个monitorexit,这是保证synchronize能一定释放锁的机制,一个是方法正常执行完释放,一个是执行过程发生异常时虚拟机释放。

monitor妈奈塔儿:

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter(莫逆塔安特儿)时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:

若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者);
若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1 ;
若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
monitorenter:

synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner(欧娜儿):拥有这把锁的线程,recursions(瑞康申)会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待。

monitorexit:exit(安可it)

能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。同时,monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

总结:synchronized同步语句块的实现使⽤的是 monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏monitorenter指令时,线程试图获取锁也就是获取monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

(2)同步方法

同样,进行反编译:

同步方法在反汇编后,会增加 ACC_SYNCHRONIZED 修饰。会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

【JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。】

synchronized的特性

原子性、可见性、有序性、可重入性

1.1 原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。

被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

注意!面试时经常会问比较synchronized和volatile,它们俩特性上最大的区别就在于原子性,volatile不具备原子性。

原子性不可被打断,一旦被打断就会出现脏读

1.2 可见性

可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

1.3 有序性

有序性值程序执行的顺序按照代码先后执行。

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

1.4 可重入性

synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

基础开篇

面向对象有什么特征,请分别解释。

答案:封装、继承、多态

封装:封装的主要作用是为了实现代码之间的“高内聚、低耦合”,通常认为封装是把数据和操作数据的方法绑定起来,对数据访问只能用过已定义的接口,一般来说会将类的成员属性定义为私有,这样其他类就无法直接访问,实现了数据的安全性。

继承:继承是指获取已有的类的继承信息并创建类的过程,子类承父类的属性和行为。同时可以精选属性的添加和行为的修改。提高了代码的可重用性和扩展性。

多态:指允许不同子类的对象对同一消息做出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做不了相同的事情。多态分为编译时的多态和运行时的多态,方法的重载其实就是编译时多态的体现,而方法重写则是运行时多态的体现。

Java的访问修饰符有哪些,并解释各自的作用域

1.排序都有哪几种方法?请列举。口述用JAVA实现快速排序。

冒泡排序:

排序的方法:插入排序、冒泡排序、快速排序、选择排序、归并排序。

使用快速排序方法对a[0:n-1]排序:

从a[0:n-1]中选择一个元素作为middle,该元素为支点

把余下的元素分割为两端left和right,使得left中的元素都小于等于支点,而right中的元素都是大于支点。

递归的使用快速排序方法对left进行排序

递归的使用快速排序方法对right进行排序。

所得结果为left+middle+right

/**
- 冒泡排序
- @param arr 需要排序的数组 
*/
public void bubbleSort(int[] arr){ 
    for(int i= 0;i<arr.length;i++){
        for(int j = 0;j<arr.length-i-1;j++){
            if(arr[j]  > arr[j+1]){
                int temp;
                temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }        
        }
        System.out.print(arr[i]  + ",");
    }
}

2. String底层是怎么实现的

String类的一个最大特性是不可修改性,而导致其不可修改的原因是在String内部定义了一个常量数组final char数组,因此每次对字符串的操作实际上都会另外分配分配一个新的常量数组空间

· 初始化

public final class String    implements java.io.Serializable, Comparable<String>, CharSequence {
   //数组定义为常量,不可修改   
   private final char value[];
    public String() {
        this.value = new char[0];
    }
//实例化字符串
public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

3.  String,StringBuffer的和StringBuilder的区别?

String:  字符串不可变,适用于字符串内容不经常发生改变的时候 final char[] ch

StringBuilder: 字符串可变,适用于字符串内容经常发生改变的时候,适用于单线程(线程不安全),在单线程中,执行效率较高

StringBuffer:字符串可变,适用于字符串内容经常发生改变的时候,适用于多线程(线程安全)

(讲到此处就可以,以下是理解)

详细分析:
String 类型和 StringBuffer 类型的主要性能区别其实在于 String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生影响,特别当内存中无引用对象多了以后, JVM 的 GC 就会开始工作,那速度是一定会相当慢的。 
 而如果是使用 StringBuffer 类则结果就不一样了,每次结果都会对 StringBuffer 对象本身进行操作,而不是生成新的对象,再改变对象引用。所以在一般情况下我们推荐使用 StringBuffer ,特别是字符串对象经常改变的情况下。而在某些特别情况下, String 对象的字符串拼接其实是被 JVM 解释成了 StringBuffer 对象的拼接,所以这些时候 String 对象的速度并不会比 StringBuffer 对象慢,而特别是以下的字符串对象生成中, String 效率是远要比 StringBuffer 快的: 
 String S1 = “This is only a” + “ simple” + “ test”; 
 StringBuffer Sb = new StringBuilder(“This is only a”).append(“ simple”).append(“ test”); 
 你会很惊讶的发现,生成 String S1 对象的速度简直太快了,而这个时候 StringBuffer 居然速度上根本一点都不占优势。其实这是 JVM 的一个把戏,在 JVM 眼里,这个 
 String S1 = “This is only a” + “ simple” + “test”; 其实就是: 
 String S1 = “This is only a simple test”; 所以当然不需要太多的时间了。但大家这里要注意的是,如果你的字符串是来自另外的 String 对象的话,速度就没那么快了,譬如: 
String S2 = “This is only a”; 
String S3 = “ simple”;
String S4 = “ test”; 
String S1 = S2 +S3 + S4; 
这时候 JVM 会规规矩矩的按照原来的方式去做  
在大部分情况下 StringBuffer > String 
//-------------------------StringBuffer ----------------------
Java.lang.StringBuffer线程安全的可变字符序列。一个类似于 String 的字符串缓冲区,但不能修改。虽然在任意时间点上它都包含某种特定的字符序列,但通过某些方法调用可以改变该序列的长度和内容。 
可将字符串缓冲区安全地用于多个线程。可以在必要时对这些方法进行同步,因此任意特定实例上的所有操作就好像是以串行顺序发生的,该顺序与所涉及的每个线程进行的方法调用顺序一致。 
StringBuffer 上的主要操作是 append 和 insert 方法,可重载这些方法,以接受任意类型的数据。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符追加或插入到字符串缓冲区中。append 方法始终将这些字符添加到缓冲区的末端;而 insert 方法则在指定的点添加字符。 
例如,如果 z 引用一个当前内容是“start”的字符串缓冲区对象,则此方法调用 z.append("le") 会使字符串缓冲区包含“startle”,而 z.insert(4, "le") 将更改字符串缓冲区,使之包含“starlet”。 
在大部分情况下 StringBuilder > StringBuffer 
java.lang.StringBuilder一个可变的字符序列是5.0新增的。此类提供一个与 StringBuffer 兼容的 API,但不保证同步。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。如果可能,建议优先采用该类,因为在大多数实现中,它比 StringBuffer 要快。两者的方法基本相同.

4. Java的访问修饰符有哪些?

public:使用public修饰,对所有类可以访问。 
protected:使用protected修饰,对同一包内和不同包所有子类可以访问。
缺省:不使用任何修饰符,在同一包内可以访问。 
private:使用private修饰,在同一类内可以访问

5.  接口和抽象类的区别是什么?(能讲多少讲多少)

应用方面的区别:

接口更多的是在系统架构设计方法发挥作用,主要用于定义模块之间的通信契约

抽象类在代码实现方面发挥作用,可以实现代码的重用。

( 举项目中的例子讲)

语法方面的区别:

(1)抽象类和接口都不能直接实例化,如果要实例化,抽象类对象必须指向实现所有抽象方法的子类对象, 接口对象必须指向实现所有接口方法的类对象。

(2)抽象类要被子类继承,接口要被类实现。

(3)接口中只有全局常量与抽象方法;

抽象类中可以有抽象方法与具体方法实现,可以有类变量与实例变量

(4)接口可继承接口,并可多继承接口;但抽象类只能单继承

6. Java 支持多继承么?

不支持,Java 类不支持多继承。每个类都只能继承一个类,但是可以实现多个接口

7. Java中的泛型是什么 ? 使用泛型的好处是什么?

泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法。

Java语言引入泛型的好处是安全简单。泛型的好处是在编译的时候检查类型安全,并且所有的强制转换都是自动和隐式的,提高代码的重用率。

8.  try与return

try{}里有一个return语句,那么紧跟在这个try后的finally{}里的code会不会被执行,什么时候被执行,在return前还是后?

int xx = 10;
try{
  return xx;
}finally{ 
  xx = 20;
}
a) finally: 用于释放资源,节省内存,语法上无论是否有异常,都会执行
b) return : 方法结束
c) 先执行return ,再执行finally

9. 在finally中,修改return 的返回值变量,最后返回值究竟有没有发生改变?

若返回值类型是:基本数据类型,finally 中修改返回值变量,返回值不会改变【值传递】

若返回值类型是:引用数据类型,finally 中修改返回值变量,返回值会改变【引用传递】

10. final, finally, finalize 的区别?

final:修饰符有三种用法:

(1)修饰类:表示该类不能被继承,即不能有子类

(2)修饰方法:表示方法不能被重写;

(3)修饰变量:表示变量只能一次赋值以后值不能被修改(常量)。

finally:用在异常结构中

通常放在try…catch的后面构造总是执行代码块,可以将释放外部资源的代码写在finally块中,即finally的作用是释放资源,节省内存。

finalize:Object类中GC相关的方法

Object类中的方法,Java中允许使用finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写finalize() 方法可以整理系统资源或者执行其他清理工作。

11.  ==与equals的区别

(1)基本数据类型比较,用双等号(==),比较的是他们的值。

(2)引用数据类型比较,

  a. 使用==比较,比较的是他们在内存中的存放地址,所以,除非是同一个new出来的对象,比较后的结果为true,否则结果为false。( JAVA当中所有的类都是继承于Object这个基类的,在Object中的基类中定义了一个equals的方法,这个方法的初始行为是比较对象的内存地 址,但在一些类库当中这个方法被覆盖掉了,如String,Integer,Date在这些类当中equals有其自身的实现,而不再是比较类在堆内存中的存放地址了。--这部分帮助理解)

b. 使用equals比较,若没有重写Object类的equals方法,比较还是内存地址(==理解);

若重写Object类的equals方法,equals比较的是堆中内容是否相等,即两个对象的内容是否相同。

12.  自动装箱与拆箱

boxing装箱:将基本类型---转换成---对应的包装类;

unboxing拆箱:将包装类型---转换成---基本数据类型;

Java使用自动装箱和拆箱机制,节省了常用数值的内存开销和创建对象的开销,提高了效率,由编译器来完成,编译器会在编译期根据语法决定是否进行装箱和拆箱动作。

13. 什么是反射API?它是如何实现的?

反射是指在运行时能查看一个类的状态及特征,并能进行动态管理的功能。这些功能是通过一些内建类的反射API提供的,比如Class,Method,Field, Constructors等。使用的例子:使用Java反射API的getName方法可以获取到类名。

14. 类加载器工作机制

1.装载:将Java二进制代码导入jvm中,生成Class文件。

2.连接:

a)校验:检查载入Class文件数据的正确性

b)准备:给类的静态变量分配存储空间

c)解析:将符号引用转成直接引用

3:初始化:对类的静态变量,静态方法和静态代码块执行初始化工作。

双亲委派模型:类加载器收到类加载请求,首先将请求委派给父类加载器完成 用户自定义加载器->应用程序加载器->扩展类加载器->启动类加载器。

15. 写一个 Singleton 单例模式

在我们日常的工作中经常需要在应用程序中保持一个唯一的实例,如:IO处理,数据库操作等,由于这些对象都要占用重要的系统资源,所以我们必须限制这些实例的创建或始终使用一个公用的实例,这就是单例模式(Singleton)。

/*饿汉模式:第一种生成singleton优点:简单,缺点:生成的资源浪费*/
class singleton1{
    private singleton1(){};
    private static singleton1 s1=new singleton1();
    public static singleton1 getSingleton(){
      return s1;
   }
}
/*懒汉模式: 第二种优点:不占用资源,缺点:不是完全的线程安全,如果在if语句执行过后转移到另一线程执行,在转移回来的时候将不进行判断,又生成一次*/
class singleton2{
  private singleton2(){
  }
  private static singleton2 s2=null;
  public static synchronized singleton2 getSingleton(){
    if(s2==null)
      s2=new singleton2();
    return s2;

单例模式-线程安全

(1)多线程安全单例模式实例一(不使用同步锁)

public class Singleton {
    private static Singleton sin=new Singleton();    ///直接初始化一个实例对象
    private Singleton(){    ///private类型的构造函数,保证其他类对象不能直接new一个该对象的实例
    }
    public static Singleton getSin(){    ///该类唯一的一个public方法  
    return sin;
    }
}

上述代码中的一个缺点是该类加载的时候就会直接new 一个静态对象出来,当系统中这样的类较多时,会使得启动速度变慢 。现在流行的设计都是讲“延迟加载”,我们可以在第一次使用的时候才初始化第一个该类对象。所以这种适合在小系统。

(2)多线程安全单例模式实例二(使用同步方法)

public class Singleton {  
     private static Singleton instance;  
     private Singleton (){         
     }   
     public static synchronized Singleton getInstance(){    //对获取实例的方法进行同步
       if (instance == null)     
         instance = new Singleton();
       return instance;
     }
 }


上述代码中的一次锁住了一个方法, 这个粒度有点大 ,改进就是只锁住其中的new语句就OK。就是所谓的“双重锁”机制。

(3)多线程安全单例模式实例三(使用双重同步锁)


public class Singleton {  
     private static Singleton instance;  
     private Singleton (){
     }   
     public static Singleton getInstance(){    //对获取实例的方法进行同步
       if (instance == null){
           synchronized(Singleton.class){
               if (instance == null)
                   instance = new Singleton(); 
           }
       }
       return instance;
     }
 }

16. 字符流和字节流有什么区别?

要把一片二进制数据逐一输出到某个设备中,或者从某个设备中逐一读取一片二进制数据,不管输入输出设备是什么,我们要用统一的方式来完成这些操作,用一种抽象的方式进行描述,这个抽象描述方式起名为IO流,对应的抽象类为OutputStream和InputStream ,不同的实现类就代表不同的输入和输出设备,它们都是针对字节进行操作的。

在应用中,经常要完成是字符的一段文本输出去或读进来,用字节流可以吗?计算机中的一切最终都是二进制的字节形式存在。对于“中国”这些字符,首先要得到其对应的字节,然后将字节写入到输出流。读取时,首先读到的是字节,可是我们要把它显示为字符,我们需要将字节转换成字符。由于这样的需求很广泛,人家专门提供了字符流的包装类。

底层设备永远只接受字节数据,有时候要写字符串到底层设备,需要将字符串转成字节再进行写入。字符流是字节流的包装,字符流则是直接接受字符串,它内部将串转成字节,再写入底层设备,这为我们向IO设备写入或读取字符串提供了一些方便。

17. 多线程编程的好处是什么?

在多线程程序中,多个线程被并发的执行以提高程序的效率,CPU不会因为某个线程需要等待资源而进入空闲状态。多个线程共享堆内存(heap memory),因此创建多个线程去执行一些任务会比创建多个进程更好。

18. 三种创建多线程方式

基于什么创建

创建的方式

Thread类

继承Thread

Runnable接口

实现Runnable

接口

callable接口

实现callable

接口

实现callable接口

1.创建一个实现Callable接口的类。
2.在这个实现类中实现Callable接口的call()方法,并创建这个类的对象。
3.将这个Callable接口实现类的对象作为参数传递到FutureTask类的构造器中,创建FutureTask类的对象。
4.将这个FutureTask类的对象作为参数传递到Thread类的构造器中,创建Thread类的对象,并调用这个对象的start()方法。
class MyCallable implements Callable{
    @Override
    public Object call() throws Exception {
           System.out.println("实现Callable接口创建多线程");
           return null;
     }  
}
public class ThreadCreate3 {
    public static void main(String[] args) {
        MyCallable myCallable = new MyCallable();
        FutureTask futureTask = new FutureTask(myCallable);
        Thread thread = new Thread(futureTask);
        thread.start();
}

Runnable接口和Callable接口有何区别

相同点

  1. Runnable和Callable都是接口
  2. 都可以编写多线程程序
  3. 都采用Thread.start()启动线程

不同点

  1. Runnable接口run方法无返回值,Callable接口call方法有返回值,是个泛型,和Futrue和FutureTask配合用来获取异步执行结果。
  2. Runnable接口run方法只能抛出运行时的异常,且无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息。

:Callable接口支持返回执行结果,需要调用FutureTask.get()得到,此方方法堵塞主线程继续往下执行,如果不调用就不会堵塞。

19. 启动一个线程是用run()还是start()?

JVM启动时会有一个由主方法所定义的线程。可以通过创建Thread的实例来创建新的线程。每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。

Start()让线程进入就绪状态

Run() 是线程正在运行状态

20. volatile 关键字的作用

Java 提供了 volatile 关键字是线程同步的轻量级实现,用来保证可见性和有序性(禁止指令重排),volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

对于加了 volatile关键字的成员变量,在对这个变量进行修改时,全直接将CPU高级缓存中的数据送回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性
在对 volatile修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性
volatile可以和CAS 结合,来保证原子性。

21. volatile是怎样实现了?

1. volatile实现内存可见性原理:

一个问题:本地内存和主内存之间的值不一致,导致内存不可见。

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。将当前处理器缓存行的数据写回系统内存,这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效,当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

2. volatile实现有序性原理:

为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。

内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

22. 什么是CAS

CAS即Compare And Swap,比较并替换。是一条CPU并发原语,Java中可以通过CAS操作来保证原子性,它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。
CAS并发原语提现在Java语言中就是sun.misc.UnSafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我实现CAS汇编指令。这是一种完全依赖于硬件功能,通过它实现了原子操作。原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。
Unsafe:CAS的核心类,Java方法无法直接访问内存,需要通过本地方法native来访问,在Unsafe中所有方法都是native方法,用来直接操作内存,执行相应的任务。
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。类似于乐观锁。CAS自旋的概率会比较大,从而浪费更多的CPU资源。在这个过程中可能存在ABA问题:
当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次(A->B->A),而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。

23. ABA的问题的解决方式

ABA的解决方法也很简单,就是利用版本号。给变量加上一个版本号,每次变量更新的时候就把版本号加1,这样即使E的值从A—>B—>A,版本号也发生了变化,这样就解决了CAS出现的ABA问题。基于CAS的乐观锁也是这个实现原理。
JDK1.5时可以利用AtomicStampedReference类来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功
自旋:当多个线程同时操作一个共享变量时,只有一个线程可以对变量进行成功更新,其他线程均会失败,但是失败并不会被挂起,进行再次尝试,也就是自旋。Java中的自旋锁就是利用CAS来实现的。

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

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

25. 线程池的原理?

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。他的主要特点为:线程复用;控制最大并发数;管理线程。

重用存在的线程,减少对象创建销毁的开销,且提高了响应速度;有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能。

线程池类别:

  • newFixedThreadPool :一个定长线程池,可控制线程最大并发数。
  • newCachedThreadPool:一个可缓存线程池。
  • newSingleThreadExecutor:一个单线程化的线程池,用唯一的工作线程来执行任务。
  • newScheduledThreadPool:一个定长线程池,支持定时/周期性任务执行。

线程池尽量不要使用 Executors 去创建,

而是通过 ThreadPoolExecutor的方式去创建,因为Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等如果使用不当,会造成资源耗尽问题。直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险。

26. sleep() 和 wait() 有什么区别

两者都可以暂停线程的执行
类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
是否释放锁:sleep() 不释放锁;wait() 释放锁,并且会加入到等待队列中。
是否依赖synchronized关键字:sleep不依赖synchronized关键字,wait需要依赖synchronized关键字
用途不同:sleep 通常被用于休眠线程;wait 通常被用于线程间交互/通信,
用法不同:sleep() 方法执行完成后,不需要被唤醒,线程会自动苏醒,或者可以使用wait(longtimeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。

27.什么是线程死锁

死锁是指两个或两个以上进程(线程)在执行过程中,由于竞争资源或由于彼此通信造成的一种堵塞的现象,若无外力的作用下,都将无法推进,此时的系统处于死锁状态。

28. 形成死锁的四个必要条件

  1. 互斥条件:线程(进程)对所分配的资源具有排它性,即一个资源只能被一个进程占用,直到该进程被释放。
  2. 请求与保持条件:一个进程(线程)因请求被占有资源而发生堵塞时,对已获取的资源保持不放。
  3. 不剥夺条件:线程(进程)已获取的资源在未使用完之前不能被其他线程强行剥夺,只有等自己使用完才释放资源。
  4. 循环等待条件:当发生死锁时,所等待的线程(进程)必定形成一个环路,死循环造成永久堵塞。

29. 如何避免死锁

我们只需破坏形参死锁的四个必要条件之一即可。

  1. 破坏互斥条件无法破坏,我们的🔒本身就是来个线程(进程)来产生互斥
  2. 破坏请求与保持条件一次申请所有资源
  3. 破坏不剥夺条件占有部分资源的线程尝试申请其它资源,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件按序来申请资源。

30. 线程安全是什么?线程不安全是什么?

线程安全:多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。(Vector,Hashtable,StringBuffer)

线程不安全:不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。(ArrayList,LinkedList,HashMap,StringBuilder等)

31. 序列化和反序列化

  1. 序列化:就是将对象转化成字节序列的过程。
  2. 反序列化:就是讲字节序列转化成对象的过程。

为什么要序列化

  1. 持久化:对象是存储在JVM中的堆区的,但是如果JVM停止运行了,对象也不存在了。序列化可以将对象转化成字节序列,可以写进硬盘文件中实现持久化。在新开启的JVM中可以读取字节序列进行反序列化成对象。
  2. 网络传输:网络直接传输数据,但是无法直接传输对象,可在传输前序列化,传输完成后反序列化成对象。所以所有可在网络上传输的对象都必须是可序列化的。

如何实现

实现Serializable接口

32. 常用的集合框架中的类与接口

Collection为集合层级的根接口。一个集合代表一组对象,这些对象即为它的元素。Java平台不提供这个接口任何直接的实现。 
Set是一个不能包含重复元素的集合。这个接口对数学集合抽象进行建模,被用来代表集合,就如一副牌。
  常用实现子类:HashSet,TreeSet  
List是一个可以包含重复元素的集合。你可以通过它的索引来访问任何元素。List更像长度动态变换的数组。
  常用实现子类:ArrayList,LinkedList、Vector
Queue: 队列,先进先出 
Map是一个将key映射到value的对象.一个Map不能包含重复的key:每个key最多只能映射一个value。
一些其它的接口有Queue、Dequeue、SortedSet、SortedMap和ListIterator。

33.  ArrayList、LinkedList 、Vector的区别

(1) ArrayList本质是数组 ,线程不同步,ArrayList替代了Vector,查询元素的速度非常快,默认大小10,1.5倍长度扩容

- 频繁的添加操作:可能会造成扩容,内存浪费与大量元素复制移位,随着数据量增多,时空复杂度会增大;

- 频繁的删除操作:可能会出现大量元素的移位

- 频繁的取元素操作:能直接根据数组下标定位元素,因为数据在数组中的物理地址是连续的;

- 【所以:ArrayList 适用于元素的随机访问】

(2) LinkedList 本质是链表,线程不同步,增删元素的速度非常快

- 频繁的添加操作:只需要一个元素内存消耗

- 频繁的删除操作:只需要元素指针重新连接

- 频繁的取元素操作:需要遍历整个链表,因为链表的物理地址是不连续的

- 【所以:LinkedList适用于元素的大量添加与删除】

(3) Vector:底层的数据结构就是数组,线程同步,Vector无论查询和增删。默认大小10,2倍长度扩容。


34.  Collection与Collections的区别?

Collection 是Java集合的根接口,下面有List,Set等子接口

Collections 是集合的工具类,提供了对集合进行排序,查询等方法。

35. 为什么要重写equals和hashcode方法?

(1) 为什么要重写equals方法

Java中的集合(Collection)有两类,一类是List,再有一类是Set。 前者集合内的元素可以重复;后者元素不可重复。

那么我们怎么判断两个元素是否重复呢? 这就是Object.equals方法了。

通常想查找一个集合中是否包含某个对象,就是逐一取出每个元素与要查找的元素进行比较,当发现某个元素与要查找的对象进行equals方法比较的结果相等时,则停止继续查找并返回肯定的信息,否则返回否定的信息。

如果一个集合中有很多元素譬如成千上万的元素,并且没有包含要查找的对象时,则意味着你的程序需要从该集合中取出成千上万个元素进行逐一比较才能得到结论,于是,有人就发明了一种【【哈希算法来提高从集合中查找元素的效率】】,这种方式将集合分成若干个存储区域,每个对象可以计算出一个哈希码,可以将哈希码分组,每组分别对应某个存储区域,根据一个对象的哈希码就可以确定该对象应该存储的那个区域.

(2) 为什么要重写hashCode方法

hashCode方法返回的就是根据对象的内存地址换算出的一个值。这样一来,当集合要添加新的元素时,

先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。

如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了;

如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了,

不相同就散列其它的地址。这样一来实际调用equals方法的次数就大大降低了,几乎只需要一两次

总结:

先通过hashcode方法返回的值,直接定位元素应该存储的物理位置,位置上没有元素则存放,若有元素,再比较对象的equals方法,从而大大减少equals方法调用,提高比较效率。 (面试先用这一句话总结)

36.  HashMap和Hashtable区别

Hashtable<K,V>

底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个Hashtable,效率低,ConcurrentHashMap做了相关优化

初始size为11,扩容:newsize = olesize*2+1

计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length

HashMap<K,V>

底层数组+链表实现+(红黑树),可以存储null键和null值,线程不安全

初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂

扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)

当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀

计算index方法:index = hash & (tab.length – 1)

HashMap概述

HashMap存储的是key-value的键值对,允许key为null,也允许value为null。HashMap内部为数组+链表的结构,会根据key的hashCode值来确定数组的索引(确认放在哪个桶里),如果遇到索引相同的key,桶的大小是2,如果一个key的hashCode是7,一个key的hashCode是3,那么他们就会被分到一个桶中(hash冲突),如果发生hash冲突,HashMap会将同一个桶中的数据以链表的形式存储,但是如果发生hash冲突的概率比较高,就会导致同一个桶中的链表长度过长,遍历效率降低,所以在JDK1.8中如果链表长度到达阀值(默认是8),就会将链表转换成红黑二叉树。

HashMap数据结构


//Node本质上是一个Map.存储着key-value
   static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;             //保存该桶的hash值
        final K key;                //不可变的key
        V value;                    
        Node<K,V> next;      //指向一个数据的指针
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
       }

从源码上可以看到,Node实现了Map.Entry接口,本质上是一个映射(k-v)

刚刚也说过了,有时候两个key的hashCode可能会定位到一个桶中,这时就发生了hash冲突,如果HashMap的hash算法越散列,那么发生hash冲突的概率越低,如果数组越大,那么发生hash冲突的概率也会越低,但是数组越大带来的空间开销越多,但是遍历速度越快,这就要在空间和时间上进行权衡,这就要看看HashMap的扩容机制,在说扩容机制之前先看几个比较重要的字段

//默认桶16个
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认桶最多有2^30个
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//能容纳最多key_value对的个数
 int threshold;
//一共key_value对个数
int size;

threshold=负载因子 * length,也就是说数组长度固定以后, 如果负载因子越大,所能容纳的元素个数越多,如果超过这个值就会进行扩容(默认是扩容为原来的2倍),0.75这个值是权衡过空间和时间得出的,建议大家不要随意修改,如果在一些特殊情况下,比如空间比较多,但要求速度比较快,这时候就可以把扩容因子调小以较少hash冲突的概率。相反就增大扩容因子(这个值可以大于1)。

size就是HashMap中键值对的总个数。还有一个字段是modCount,记录是发生内部结构变化的次数,如果put值,但是put的值是覆盖原有的值,这样是不算内部结构变化的。

因为HashMap扩容每次都是扩容为原来的2倍,所以length总是2的次方,这是非常规的设置,常规设置是把桶的大小设置为素数,因为素数发生hash冲突的概率要小于合数,比如HashTable的默认值设置为11,就是桶的大小为素数的应用(HashTable扩容后不能保证是素数)。HashMap采用这种设置是为了在取模和扩容的时候做出优化。

hashMap是通过key的hashCode的高16位和低16位异或后和桶的数量取模得到索引位置,即key.hashcode()(hashcode>>>16)%length,当length是2n时,h&(length-1)运算等价于h%length,而&操作比%效率更高。而采用高16位和低16位进行异或,也可以让所有的位数都参与越算,使得在length比较小的时候也可以做到尽量的散列。

在扩容的时候,如果length每次是2^n,那么重新计算出来的索引只有两种情况,一种是 old索引+16,另一种是索引不变,所以就不需要每次都重新计算索引。

确定哈希桶数据索引位置

//方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

HashMap的put方法实现

思路如下:

1.table[]是否为空

2.判断table[i]处是否插入过值

3.判断链表长度是否大于8,如果大于就转换为红黑二叉树,并插入树中

4.判断key是否和原有key相同,如果相同就覆盖原有key的value,并返回原有value

5.如果key不相同,就插入一个key,记录结构变化一次

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
//判断table是否为空,如果是空的就创建一个table,并获取他的长度
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//如果计算出来的索引位置之前没有放过数据,就直接放入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
//进入这里说明索引位置已经放入过数据了
            Node<K,V> e; K k;
//判断put的数据和之前的数据是否重复
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))   //key的地址或key的equals()只要有一个相等就认为key重复了,就直接覆盖原来key的value
                e = p;
//判断是否是红黑树,如果是红黑树就直接插入树中
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//如果不是红黑树,就遍历每个节点,判断链表长度是否大于8,如果大于就转换为红黑树
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
//判断索引每个元素的key是否可要插入的key相同,如果相同就直接覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
//如果e不是null,说明没有迭代到最后就跳出了循环,说明链表中有相同的key,因此只需要将value覆盖,并将oldValue返回即可
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
//说明没有key相同,因此要插入一个key-value,并记录内部结构变化次数
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

HashMap的get方法实现

实现思路:

1.判断表或key是否是null,如果是直接返回null

2.判断索引处第一个key与传入key是否相等,如果相等直接返回

3.如果不相等,判断链表是否是红黑二叉树,如果是,直接从树中取值

4.如果不是树,就遍历链表查找

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果表不是空的,并且要查找索引处有值,就判断位于第一个的key是否是要查找的key
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
//如果是,就直接返回
                return first;
//如果不是就判断链表是否是红黑二叉树,如果是,就从树中取值
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果不是树,就遍历链表
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

扩容机制

我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。


因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

HashMap 会问什么?

面试官: 那你跟我讲讲HashMap的内部数据结构?

: 目前我用的是JDK1.8版本的,内部使用数组 + 链表+红黑树;

: 方便我给您画个数据结构图吧:

面试官: 那你清楚HashMap的数据插入原理吗?

: 画个图比较清楚,如下:

  1. 判断数组是否为空,为空进行初始化;
  2. 不为空,计算 k 的 hash 值,通过(n - 1) & hash计算应当存放在数组中的下标 index;
  3. 查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;
  4. 存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据(onlyIfAbsent为false);
  5. 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;(如果当前节点是树型节点证明当前已经是红黑树了)
  6. 如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8并且数组长度大于64, 大于的话链表转换为红黑树;
  7. 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

面试官: 刚才你提到HashMap的初始化,那HashMap怎么设定初始容量大小的吗?

: 一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16。(补充说明:实现代码如下)

static final int tableSizeFor(int cap) {
  int n = cap - 1;
  n |= n >>> 1;
  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;
  return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
123456789

补充说明:下图是详细过程,算法就是让初始二进制右移1,2,4,8,16位,分别与自己位或,把高位第一个为1的数通过不断右移,把高位为1的后面全变为1,最后再进行+1操作,111111 + 1 = 1000000 = 2 6 2^626 (符合大于50并且是2的整数次幂 )

面试官: 你提到hash函数,你知道HashMap的哈希函数怎么设计的吗?

我: hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。

面试官: 那你知道为什么这么设计吗?

: 这个也叫扰动函数,这么设计有二点原因:

  1. 一定要尽可能降低hash碰撞,越分散越好;
  2. 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

面试官: 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

: 因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为-2147483648~2147483647,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比取余%运算要快。

bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
     return h & (length-1);
}
12345

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部归零,只保留末四位
1234

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复,就无比蛋疼。

时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

右移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

结果显示,当HashMap数组长度为512的时候(2 9 2^929),也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。

另外Java1.8相比1.7做了调整,1.7做了四次移位和四次异或,但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。

下面是1.7的hash代码:

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
1234

面试官: 刚刚说到1.8对hash函数做了优化,1.8还有别的优化吗?

: 1.8还有三点主要的优化:

  1. 数组+链表改成了数组+链表+红黑树;
  2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
  3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
  4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

面试官: 你分别跟我讲讲为什么要做这几点优化;

:

  1. 防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
  2. 因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如下图所示:1.7的扩容调用transfer代码,如下所示:
  3. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
void transfer(Entry[] newTable, boolean rehash) {
  int newCapacity = newTable.length;
  for (Entry<K,V> e : table) {
    while(null != e) {
      Entry<K,V> next = e.next;
      if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
      }
      int i = indexFor(e.hash, newCapacity);
      e.next = newTable[i]; //A线程如果执行到这一行挂起,B线程开始进行扩容
      newTable[i] = e;
      e = next;
    }
  }
}
12345678910111213141516


面试官: 那HashMap是线程安全的吗?

: 不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把A线程的数据给覆盖了;还有++size这个地方也会造成多线程同时扩容等问题。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  if ((p = tab[i = (n - 1) & hash]) == null)  //多线程执行到这里
    tab[i] = newNode(hash, key, value, null);
  else {
    Node<K,V> e; K k;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    else if (p instanceof TreeNode) // 这里很重要
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  ++modCount;
  if (++size > threshold) // 多个线程走到这,可能重复resize()
    resize();
  afterNodeInsertion(evict);
  return null;
}
123456789101112131415161718192021222324252627282930313233343536373839404142

面试官: 那你平常怎么解决这个线程不安全的问题?

: Java中有Hashtable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。

Hashtable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

面试官: 那你知道ConcurrentHashMap的分段锁的实现原理吗?

: ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。

面试官: 你前面提到链表转红黑树是链表长度达到阈值,这个阈值是多少?

: 阈值是8,红黑树转链表阈值为6

面试官: 为什么是8,不是16,32甚至是7 ?又为什么红黑树转链表的阈值是6,不是8了呢?

: 因为作者就这么设计的,哦,不对,因为经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,概率说话。。因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化,为了预防这种情况的发生。

面试官: HashMap内部节点是有序的吗?

: 是无序的,根据hash值随机插入

面试官: 那有没有有序的Map?

: LinkedHashMap 和 TreeMap

面试官: 跟我讲讲LinkedHashMap怎么实现有序的?

: LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

/**
 * The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
  * The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
//链接新加入的p节点到链表后端
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
  LinkedHashMap.Entry<K,V> last = tail;
  tail = p;
  if (last == null)
    head = p;
  else {
    p.before = last;
    last.after = p;
  }
}
//LinkedHashMap的节点类
static class Entry<K,V> extends HashMap.Node<K,V> {
  Entry<K,V> before, after;
  Entry(int hash, K key, V value, Node<K,V> next) {
    super(hash, key, value, next);
  }
}
123456789101112131415161718192021222324252627

示例代码:

public static void main(String[] args) {
  Map<String, String> map = new LinkedHashMap<String, String>();
  map.put("1", "安琪拉");
  map.put("2", "的");
  map.put("3", "博客");
  for(Map.Entry<String,String> item: map.entrySet()){
    System.out.println(item.getKey() + ":" + item.getValue());
  }
}
//console输出
1:安琪拉
2:的
3:博客
1234567891011121314

面试官: 跟我讲讲TreeMap怎么实现有序的?

:TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较。

面试官那么为什么默认是16呢?怎么不是4?不是8?

: 关于这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。

太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。

所以,16就作为一个经验值被采用了

面试官: 说说你对红黑树的见解?

  • 每个节点非红即黑
  • 根节点总是黑色的
  • 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
  • 每个叶子节点都是黑色的空节点(NIL节点)
  • 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

面试官: 前面提到通过CAS 和 synchronized结合实现锁粒度的降低,你能给我讲讲CAS 的实现以及synchronized的实现原理吗?

: 下一期咋们再约时间,OK?

: 好吧,回去等通知吧!

Spring--开篇

1. Spring中什么是IoC?

控制反转( Inversion of Control )对软件而言,某一个接口具体实现的选择控制权从调用类中移除,转交给第三方容器决定。 (换而言之,即通过Spring提供的IoC容器,可以将对象之间的依赖关系交由Spring进行控制,避免硬编码所造成的过度程序耦合。)

DI(Dependency Injection) 依赖注入 让调用类对某一接口实现类的依赖关系由第三方容器注入,以移除调用类对某一接口的实现类的依赖。

如项目中controller 依赖于service层对象,不是直接在controller层中 new ServiceImpl(),

而是向Spring容器中获得service层对象(可以根据类型匹配,也可以根据类名匹配获得)

Spring的几种注入bean的方式

在Spring容器中为一个bean配置依赖注入有三种方式:

使用属性的setter方法注入

使用构造器注入

2. Spring的Scope有以下几种,通过@Scope注解来实现:

(1)Singleton:一个Spring容器中只有一个Bean的实例,此为Spring的默认配置,全容器共享一个实例。

(2)Prototype:每次调用新建一个Bean实例。

3. Spring bean生命周期

Bean 的生命周期概括起来就是 4 个阶段:

实例化(Instantiation)--》属性赋值(Populate)--》初始化(Initialization)--》销毁(Destruction)

实例化:第 1 步,实例化一个 bean 对象;

属性赋值:第 2 步,为 bean 设置相关属性和依赖;

初始化:第 3~7 步,步骤较多,其中第 5、6 步为初始化操作,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,该阶段结束,才能被用户使用;

销毁:第 8~10步,第8步不是真正意义上的销毁(还没使用呢),而是先在使用前注册了销毁的相关调用接口,为了后面第9、10步真正销毁 bean 时再执行相应的方法。

4. Spring中常用的注解

@Component:这将 java 类标记为 bean。它是任何 Spring 管理组件的通用构造型。spring 的组件扫描机制现在可以将其拾取并将其拉入应用程序环境中。

@Controller:这将一个类标记为 Spring Web MVC 控制器。标有它的 Bean 会自动导入到 IoC 容器中。

@Service:此注解是组件注解的特化。它不会对 @Component 注解提供任何其他行为。您可以在服务层类中使用 @Service 而不是 @Component,因为它以更好的方式指定了意图。

@Repository:这个注解是具有类似用途和功能的 @Component 注解的特化。它为 DAO 提供了额外的好处。它将 DAO 导入 IoC 容器,并使未经检查的异常有资格转换为 Spring DataAccessException。

@Autowired: 自动装配,默认根据类型匹配

@RestController

@RequestMapping

@GetMapping @PostMapping ...

5. Spring AOP是什么?

AOP的全称是Aspect Oriented Programming,面向切面编程。是对OOP(Object Orient Programming)的一种补充,专门用于处理一些具有横切性质的服务。分离系统中的各种关注点,将核心关注点和横切关注点分离开来。

这样就能减少系统的重复代码,降低模块间的耦合度,并有利于未来的可操作性和可维护性。

什么场合会用AOP?请列举几个

Ø 日志功能 log4j、Ø 安全检查、Ø 事务管理、Ø 缓存

AOP实现机制(原理)是什么?

Spring AOP采用JDK动态代理机制和CGlib字节码生成技术实现。

JDK动态代理:基于接口的代理,一定是基于接口,会生成目标对象的接口类型的子对象。其核心的两个类是InvocationHandler和Proxy。

CGLib代理:基于类的代理,只是它在运行期间生成的代理对象是针对目标类扩展的子类。CGLIB是高效的代码生成包,底层是依靠ASM(开源的java字节码编辑类库)操作字节码实现的,性能比JDK强.

Spring如何实现事务管理的

Spring既支持编程式事务管理(也称编码式事务),也支持声明式的事务管理

声明式事务管理:大多数情况下比编程式事务管理更好用。它将事务管理代码从业务方法中分离出来,以声明的方式来实现事务管理。事务管理作为一种横切关注点,可以通过AOP方法模块化。Spring通过Spring AOP框架支持声明式事务管理

(1)事务的传播特性

事务传播行为就是多个事务方法调用时,如何定义方法间事务的传播。Spring定义了7种传播行为:

(1)propagation_requierd:如果当前没有事务,就新建一个事务,如果已存在一个事务中,加入到这个事务中,这是Spring默认的选择。

(2)propagation_supports:支持当前事务,如果没有当前事务,就以非事务方法执行。

(3)propagation_mandatory:使用当前事务,如果没有当前事务,就抛出异常。

(4)propagation_required_new:新建事务,如果当前存在事务,把当前事务挂起。

(5)propagation_not_supported:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

(6)propagation_never:以非事务方式执行操作,如果当前事务存在则抛出异常。

(7)propagation_nested:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与 propagation_required类似的操作。

SpingBootk--开篇

1. Spring Boot 有哪些优点?

Spring Boot 主要有如下优点:

(1)容易上手,提升开发效率,为 Spring 开发提供一个更快、更广泛的入门体验。
(2)开箱即用,远离繁琐的配置。
(3)提供了一系列大型项目通用的非业务性功能,例如:内嵌Tomcat服务器、安全管理、运行数据监控、运行状况检查和外部化配置等。
(4)没有代码生成,也不需要XML配置。
(5)避免大量的 Maven 导入和各种版本冲突。

2. Spring Boot 的核心注解有哪些

启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:

@SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。

@EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项,如关闭数据源自动配置功能: @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })。

@ComponentScan:Spring组件扫描。

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
      @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
      @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

3. Spring Boot 自动配置原理是什么?

注解 @EnableAutoConfiguration, @Configuration, @ConditionalOnClass 就是自动配置的核心,

@EnableAutoConfiguration 给容器导入META-INF/spring.factories 里定义的自动配置类。

筛选有效的自动配置类。

每一个自动配置类结合对应的 xxxProperties.java 读取配置文件进行自动配置功能

4. 如何理解 Spring Boot 配置加载顺序?

在 Spring Boot 里面,可以使用以下几种方式来加载配置。

1)properties文件;

2)yaml 文件;

3)系统环境变量;

4)命令行参数;

等等……

5. Spring Boot 中的 starter 到底是什么 ?

首先它提供了一个自动化配置类,一般命名为 XXXAutoConfiguration ,在这个配置类中通过条件注解来决定一个配置是否生效(条件注解就是 Spring 中原本就有的),然后它还会提供一系列的默认配置,也允许开发者根据实际情况自定义相关配置,然后通过类型安全的属性注入将这些配置属性注入进来,新注入的属性会代替掉默认属性。正因为如此,很多第三方框架,我们只需要引入依赖就可以直接使用了。当然,开发者也可以自定义 Starter

6. 你使用了哪些 starter maven 依赖项?

使用了下面的一些依赖项

spring-boot-starter-activemq

spring-boot-starter-data-redis

spring-boot-starter-web

spring-boot-starter-aop

spring-boot-starter-cache

这有助于增加更少的依赖关系,并减少版本的冲突

7. Spring Boot启动时都做了什么

  1. SpringBoot在启动的时候从类路径下的META-INF/spring.factories中获取EnableAutoConfiguration指定的值
  2. 将这些值作为自动配置类导入容器 , 自动配置类就生效 , 帮我们进行自动配置工作;
  3. 整个J2EE的整体解决方案和自动配置都在springboot-autoconfigure的jar包中;
  4. 它会给容器中导入非常多的自动配置类 (xxxAutoConfiguration), 就是给容器中导入这个场景需要的所有组件 , 并配置好这些组件 ;
  5. 有了自动配置类 , 免去了我们手动编写配置注入功能组件等的工作

8. Spring Boot 中如何实现定时任务 ?

定时任务也是一个常见的需求,Spring Boot 中对于定时任务的支持主要还是来自 Spring 框架。

在 Spring Boot 中使用定时任务主要有两种不同的方式,一个就是使用 Spring 中的 @Scheduled 注解,另一个则是使用第三方框架 Quartz。

使用 Spring 中的 @Scheduled 的方式主要通过 @Scheduled 注解来实现。

使用 Quartz ,则按照 Quartz 的方式,定义 Job 和 Trigger 即可。

9. 网站大流量高并发访问的处理解决办法

架构层面

1、硬件升级

2、服务器集群、负载均衡、分布式

3、CDN

4、页面静态化

5、缓存技术(Memcache、Redis)

网站本地项目层面

6、数据库优化

1、数据库分表技术

2、数据库读写分离

3、表建立相应的索引

7、禁止盗链

8、控制大文件的上传下载

10.Maven常用构建命令

validate:验证项目是正确的,所有必要的信息都是可用的

compile:编译项目的源代码

test:使用适当的单元测试框架测试编译后的源代码。这些测试不应要求将代码打包或部署

package:使用已编译的代码,并将其打包成可分布格式,例如JAR。

verify:对集成测试的结果进行任何检查,以确保满足质量标准

install:将包安装到本地存储库中,以便在本地其他项目中使用该包

deploy:在构建环境中完成,将最终的包复制到远程存储库中,以便与其他开发人员和项目共享。

11.HTTP中GET POST 区别

GET : 向服务器获得资源,一般用于查询
请求的参数会显示在URL后面
请求参数内容长度有限制
浏览器会缓存请求数据

POST: 修改服务器资源,一般用于添加操作
请求的参数放在HTTP协议的body 部分,比较安全
请求参数内容长度可以很大
浏览器不会缓存请求数据


数据库 MySQL--开篇

1. 事务 transaction

(1)概念

数据库中的事务是指一个任务中有多个SQL语句要执行,这些SQL操作最终要么全部执行成功,要么全部不执行,不会存在部分成功的情况。

(2)属性【重点】 - ACID

(1)原子性(Atomicity):事务操作的是最小的业务逻辑单元,整个过程如原子操作一样,最终要么全部成功,要么全部不执行。

(2)一致性(Consistency):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。

(3)隔离性(Isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

(4)持久性(Durability):事务一旦提交,对数据库中数据的改变就应该是永久性的,不能再回滚。

(3)隔离级别(4个)

读未提交:read uncommitted :事务A和事务B,事务A未提交的数据,事务B可以读取到,出现“脏读”。

读已提交:read committed:事务A和事务B,事务A提交的数据,事务B才能读取到,避免了脏读,但是可能出现不可重复读。          

可重复读:repeatable read:一个事务A内,多次读同一个数据,在这个事务A还没结束时,其他事务不能访问该数据(包括了读写),这样就可以在同一个事务内两次读到的数据是一样的,避免了不可重复读和脏读,但是有时可能会出现幻读;

可序化(串行化):serializable :

事务只能一个接着一个地执行,但不能并发执行,如果仅仅通过“行级锁”是无法实现序列化的,必须通过其他机制保证新插入的数据不会被执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也是最高的,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读

(4)传播特性

PROPAGATION_REQUIRED--支持当前事务,如果当前没有事务,就新建一个事务。这是最常见的选择。

PROPAGATION_SUPPORTS--支持当前事务,如果当前没有事务,就以非事务方式执行。

PROPAGATION_MANDATORY--支持当前事务,如果当前没有事务,就抛出异常。

PROPAGATION_REQUIRES_NEW--新建事务,如果当前存在事务,把当前事务挂起。

PROPAGATION_NOT_SUPPORTED--以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

PROPAGATION_NEVER--以非事务方式执行,如果当前存在事务,则抛出异常。

2. 索引

(1)索引的概念

索引 为了提高查询效率, 是对数据库表中一列或多列的值进行排序的一种结构,如同书的目录,可以加快书籍内容的查询效率

(2)索引的优点

1、索引大大减小了服务器需要扫描的数据量

2、索引可以帮助服务器避免排序和临时表(尽量避免文件排序,而是使用索引排序)

3、索引可以将随机IO变成顺序IO

(3)索引的缺点

创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加

索引需要占物理空间,除了数据表占用数据空间之外,每一个索引还要占用一定的物理空间,如果需要建立聚簇索引,那么需要占用的空间会更大

对表中的数据进行增、删、改的时候,索引也要动态的维护,这就降低了整数的维护速度

如果某个数据列包含许多重复的内容,为它建立索引就没有太大的实际效果。

对于非常小的表,大部分情况下简单的全表扫描更高效;

(4)索引的分类

数据库默认建立的索引是给唯一键建立的,比如主键列就默认成为了索引列

(1) 主键索引(唯一且非空)

(2) 唯一索引(唯一可为空)

(3) 普通索引(普通字段的索引)

(4) 全文索引(一般是varchar,char,text类型建立的,但很少用)

(5) 组合索引(多个字的建立的索引)

(5)哪些列适合作为索引列 ? ( 索引建立的原则)

在经常需要搜索的列上,可以加快搜索的速度

在作为主键的列上,强制该列的唯一性和组织表中数据的排列结构

在经常用在连接(JOIN)的列上,这些列主要是一外键,可以加快连接的速度

在经常需要根据范围(<,<=,=,>,>=,BETWEEN,IN)进行搜索的列上创建索引,因为索引已经排序,其指定的范围是连续的

在经常需要排序(order by)的列上创建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;

在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度

(5)哪些列不该创建索引的列

1、 对于那些在查询中很少使用或者参考的列不应该创建索引。

(若列很少使用到,因此有索引或者无索引,并不能提高查询速度。相反,由于增加了索引,反而降低了系统的维护速度和增大了空间需求。)

3.、对于那些只有很少数据值或者重复值多的列也不应该增加索引。

(这些列的取值很少,例如人事表的性别列,在查询的结果中,结果集的数据行占了表中数据行的很大比例,即需要在表中搜索的数据行的比例很大。增加索引,并不能明显加快检索速度。)

3、 对于那些定义为text, image和bit数据类型的列不应该增加索引。

(这些列的数据量要么相当大,要么取值很少。)

4、 当该列修改性能要求远远高于检索性能时,不应该创建索引。(修改性能和检索性能是互相矛盾的)

(6)MySQL中索引结构(底层的数据结构)

B-TREE 
B+TREE  : MySQL的默认存储引擎InnoDB, 使用的是B+Tree。【重点描述】
HASH 等

<1> B树



B树的特征:

(1)关键字集合分布在整颗树中;

(2)任何一个关键字出现且只出现在一个结点中;

(3)搜索有可能在非叶子结点结束;

(4)其搜索性能等价于在关键字全集内做一次二分查找;

(5)自动层次控制;

<2> B+树【重点】



B+树的特征:

(1)所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;

(2)不可能在非叶子结点命中;

(3)非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;

(4)每一个叶子节点都包含指向下一个叶子节点的指针,从而方便叶子节点的范围遍历。

(5)更适合文件索引系统;

<3>Hash



哈希索引就是采用一定的哈希算法,把键值换算成新的哈希值,检索时不需要类似B+树那样从根节点到叶子节点逐级查找,只需一次哈希算法即可立刻定位到相应的位置,速度非常快。

(7)索引的结构,为什么用B+树?【重点】

1、索引节点没有数据,比较小,能够完全加载到内存中

2、而且叶子节点之间都是链表的结构,所以B+Tree也是【可以支持范围查询】的,而B树每个节点key和data在一起,则无法区间查找

3、B+树中因为数据都在叶子节点,每次查询的【时间复杂度是稳定】的,因此稳定性保证了

理解分析【重点】

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。

B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,B+树只需要去遍历叶子节点就可以实现整棵树的遍历,而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。

(8) B树与B+树对比

1、B+树非叶子节点不存在数据只存索引,B树非叶子节点存储数据

2、B+树查询效率更高。B+树使用双向链表串连所有叶子节点,区间查询效率更高(因为所有数据都在B+树的叶子节点,扫描数据库 只需扫一遍叶子结点就行了),但是B树则需要通过中序遍历才能完成查询范围的查找。

3、B+树查询效率更稳定。B+树每次都必须查询到叶子节点才能找到数据,而B树查询的数据可能不在叶子节点,也可能在,这样就会造成查询的效率的不稳定

4、B+树的磁盘读写代价更小。B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B树更小,通常B+树矮更胖,高度小查询产生的I/O更少。

3. MySQL与Oracle的区别

1. MySQL优点: 体积小、速度快、总体拥有成本低,开源支持多种操作系统,是开源数据库,提供的接口支持多种语言连接操作。

  MySQL缺点:不支持热备份, 最大的缺点是其安全系统,主要是复杂而非标准,另外只有到调用mysqladmin来重读用户权限时才发生改变,没有一种存储过程语言,这是对习惯于企业级数据库的程序员的最大限制。    

2. SqlServer优点:易用性、适合分布式组织的可伸缩性、用于决策支持的数据仓库功能、与许多其他服务器软件紧密关联的集成性、良好的性价比等。

  SqlServer缺点:

开放性 :SQL Server 只能windows上运行,没有丝毫开放性操作系统系统稳定 。

安全性:没有获得任何安全证书。  

3. Oracle 优点

(1)开放性好:所有主流平台上运行(包括 windows)完全支持;

(2)可伸缩性,并行性:oracle 有并行服务器提供高用性和高伸缩性簇解决方;

(3)安全性高:获得最高认证级别的ISO标准认证。

Oracle缺点:

(1)对硬件的要求很高;

(2)价格比较昂贵;

(3)管理维护麻烦一些;

(4)操作比较复杂,需要技术含量较高。

4. 怎么写出高性能SQL语句

1、添加索引 ,索引是可以帮助快速高效查询数据的一种数据结构,优先考虑where、group by使用到的字段

2、分表分库技术(取模分表=水平分割,垂直分割)  

(1)什么时候分库?

电商项目将一个项目进行分割,拆成多个小项目,每个小项目有自己单独的数据库,互不影响----垂直分割 会员数据库,订单数据库,支付数据库

(2)什么时候分表?

分表 根据业务需求,比如存放日志(按年分表存放)

水平分割(取模算法)用于均匀的分表...

3. 尽量避免使用select *,返回无用的字段会降低查询效率。

4. 尽量避免使用in 和not in,会导致数据库引擎放弃索引进行全表扫描。

5. 尽量避免使用or,会导致数据库引擎放弃索引进行全表扫描

6. 尽量避免在字段开头模糊查询,会导致数据库引擎放弃索引进行全表扫描。

7. 尽量避免进行null值的判断,会导致数据库引擎放弃索引进行全表扫描。

8. 尽量避免在where条件中等号的左侧进行表达式、函数操作,会导致数据库引擎放弃索引进行全表扫描

5.  三大范式

在设计表 的时候,需要遵循---范式Normal Form
第一范式(1NF):根据业务需求,该列分割到不可再分割 的列,具有原子性
第二范式(2NF):先满足第一范式,确保表中的每列都和主键相关
第三范式(3NF):先满足第一范式和第二范式,确保表中的每列直接依赖于主键列,而不是间接依赖关系

6. MySQL的锁机制

(1)锁机制起步

锁是计算机用以协调多个进程间并发访问同一共享资源的一种机制。MySQL中为了保证数据访问的一致性与有效性等功能,实现了锁机制,MySQL中的锁是在服务器层或者存储引擎层实现的。

(2)行锁与表锁

InnoDB既支持行锁,也支持表锁

行锁是作用在索引上的,

当没有查询列没有索引时,InnoDB就不会去搞什么行锁了,毕竟行锁一定要有索引,所以它现在搞表锁,把整张表给锁住了

补充:  InnoDB中的聚簇索引

每一个InnoDB表都需要一个聚簇索引,有且只有一个

(1) 若给表定义一个主键,那么MySQL将使用主键作为聚簇索引

(2) 若不为定义一个主键,那么MySQL将会把第一个唯一索引(而且要求NOT NULL)作为聚簇索引

(3) 若以上都没有,MySQL将自动创建一个名字为GEN_CLUST_INDEX的隐藏聚簇索引

两种锁的比较

表锁:加锁过程的开销小,加锁的速度快;不会出现死锁的情况;锁定的粒度大,发生锁冲突的几率大,并发度低;

  • 一般在执行DDL语句时会对整个表进行加锁,比如说 ALTER TABLE 等操作;
  • 如果对InnoDB的表使用行锁,被锁定字段不是主键,也没有针对它建立索引的话,那么将会锁整张表;
  • 表级锁更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用,如Web 应用。

行锁:加锁过程的开销大,加锁的速度慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高;

  • 最大程度的支持并发,同时也带来了最大的锁开销。
  • 在 InnoDB 中,除单个 SQL 组成的事务外,锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。
  • 行级锁只在存储引擎层实现,而 MySQL 服务器层没有实现。 行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。

(3) InnoDB锁模式

<1> InnoDB中的行锁

    InnoDB实现了以下两种类型的行锁:

共享锁(S):加了锁的记录,所有事务都能去读取但不能修改,同时阻止其他事务获得相同数据集的排他锁;

排他锁(X):允许已经获得排他锁的事务去更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁;

<2> InnoDB表锁——意向锁

意向锁是表级锁,分为读意向锁(IS锁)和写意向锁(IX锁)。当事务要在记录上加上行锁时,要首先在表上加上意向锁。这样判断表中是否有记录正在加锁就很简单了,只要看下表上是否有意向锁就行了,从而就能提高效率。

意向锁之间是不会产生冲突的,它只会阻塞表级读锁或写锁。意向锁不于行级锁发生冲突

7. MySQL存储引擎

存储引擎提供存储机制、索引技巧、锁定水平等功能,使用不同的存储引擎还可以获得特定的功能。

MySQL 常用的存储引擎: MyISAM, InnoDB

MyISAM

不支持行锁(MyISAM只有表锁),读取时对需要读到的所有表加锁,写入时则对表加排他锁;

不支持事务

不支持外键

不支持崩溃后的安全恢复

在表有读取查询的同时,支持往表中插入新纪录

支持BLOB和TEXT的前500个字符索引,支持全文索引

支持延迟更新索引,极大地提升了写入性能

对于不会进行修改的表,支持 压缩表 ,极大地减少了磁盘空间的占用

其中:

Mysql的行锁和表锁( 锁是计算机协调多个进程或纯线程并发访问某一资源的机制)

表级锁: 每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;

行级锁: 每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高;

InnoDB

支持行锁,采用MVCC来支持高并发,有可能死锁

支持事务

支持外键

支持崩溃后的安全恢复

不支持全文索引

二者的常见对比

1、count运算上的区别: 因为MyISAM缓存有表meta-data(行数等),因此在做COUNT(*)时对于一个结构很好的查询是不需要消耗多少资源的。而对于InnoDB来说,则没有这种缓存。

2、是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行数度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持事务,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。

3、是否支持外键: MyISAM不支持,而InnoDB支持。

总之:MyISAM更适合读密集的表,而InnoDB更适合写密集的的表。一般来说,如果需要事务支持,并且有较高的并发读取频率(MyISAM的表锁的粒度太大,所以当该表写并发量较高时,要等待的查询就会很多了),InnoDB是不错的选择。如果你的数据量很大(MyISAM支持压缩特性可以减少磁盘的空间占用),而且不需要支持事务MyISAM是最好的选择

9. SQL

(1)DDL 建表:学生表、课程表、成绩表

(2)DML 常用查询

数据库连接池

第一、连接池的建立。一般在系统初始化时,连接池会根据系统配置建立,并在池中创建了几个连接对象,以便使用时能从连接池中获取。
例如Vector、Stack等。
第二、连接池的管理。连接池管理策略是连接池机制的核心,当客户请求数据库连接时,首先查看连接池中是否有空闲连接,如果存在空闲连接,则将连接分配给客户使用;如果没有空闲连接,
则查看当前所开的连接数是否已经达到最大连接数,如果没达到就重新创建一个连接给请求的客户;如果达到就按设定的最大等待时间进行等待,
如果超出最大等待时间,则抛出异常给客户。 当客户释放数据库连接时,先判断该连接的引用次数是否超过了规定值,
如果超过就从连接池中删除该连接,否则保留为其他客户服务。该策略保证了数据库连接的有效复用,避免频繁的建立、
释放连接所带来的系统资源开销。
第三、连接池的关闭。当应用程序退出时,关闭连接池中所有的连接,释放连接池相关的资源,该过程正好与创建相反

Redis--开篇

1. Redis 为何这么快?

Redis 是 C 语言开发的一个开源的高性能键值对(key-value)的【内存数据库】,可以用作数据库、缓存、消息中间件等。它是一种 NoSQL(not-only sql非关系型数据库)的数据库

1)基于内存的读写,10w/s 的QPS;绝大部分请求是纯粹的内存操作,非常快速,跟传统的磁盘文件数据存储相比,避免了通过磁盘IO读取到内存这部分的开销。

2)单线程减少上下文切换,同时保证原子性;

3)IO多路复用,可以处理并发的链接

4)支持丰富数据类型,支持string,list,set,sorted set,hash

2. Redis 的数据类型?

包括String,List,Set,Zset,Hash

3. Redis 是单进程单线程的 ?

Redis 是单进程单线程的, redis 利用队列技术将并发访问变为串行访问, 消除了传统数据库串行控制的开销。

4. 一个字符串类型的值能存储最大容量是多少?

512M

5. Redis雪崩、穿透、击穿

(1)Redis雪崩



概念:

雪崩就是指缓存中大批量热点数据过期后系统涌入大量查询请求,因为大部分数据在Redis层已经失效,请求渗透到数据库层,大批量请求犹如洪水一般涌入,引起数据库压力造成查询堵塞甚至宕机。

解决方案

(1)  缓存雪崩的事前事中事后的解决方案1

【Redis集群高可用】

事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。

  即分布式集群,其中一台Redis服务器挂掉,其他Redis主机再服务,实现高可用

【多缓存、限流、熔断机制】    

事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。

   即先查询本地ehcache 缓存 ----->查询Redis -----> MySQL

   其中再实现熔断器限流&降级

【缓存预热】    

事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

好处:数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。 - 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。 - 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。

(2) 解决方案2 【加锁】

用加锁或者队列的方式保证来不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层数据库。

(3) 解决方案3 【过期时间均匀分布】

将缓存失效时间分散开,比如每个key的过期时间是随机,防止同一时间大量数据过期现象发生,这样不会出现同一时间全部请求都落在数据库层,如果缓存数据库是分布式部署,将热点数据均匀分布在不同Redis和数据库中,有效分担压力,别让一个人扛。

(4)解决方案4 【永不过期】

简单粗暴,让Redis数据永不过期(如果业务准许,比如不用更新的名单类)。当然,如果业务数据准许的情况下可以,比如中奖名单用户,每期用户开奖后,名单不可能会变了,无需更新。

(2)Redis穿透


概念

对于系统A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。那 4000 个请求数据,都不存在于缓存中,所以需要每次去数据库里查,可能也查不到。这样Redis就失效,不发挥作用了。也导致数据库直接面临大量请求。如数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

解决方案

1、IP恶意频繁访问进行拦截

2、请求参数进行过滤

3、将不存在于缓存、数据库的数据,设置为null , 保存到redis 缓存中,并且设置过期时间 (redis : -1-null, -2- null )保证在短期内 用户恶意的请求 从redis 缓存中加载数据

(3)Redis击穿

概念

缓存击穿:某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,

当这个 key 在【失效的瞬间】,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

解决方案

可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。

使用互斥锁(mutex key)

简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。

总结:

雪崩: 【大批量热点数据过期】

1、数据的过期时间采用随机数,均匀分布

2、数据的过期时间设置为永不过期

3、搭建redis集群,实现高可用

4、本地ehcache + redis + 熔断&降级

5、定期加载热数据到Redis缓存

穿透:【恶意请求,数据不存在于缓存、数据库中,如 id = -1,-2】

1、IP恶意频繁访问进行拦截

2、请求参数进行过滤

3、将不存在于缓存、数据库的数据,设置为null , 保存到redis 缓存中,并且设置过期时间 (redis : -1-null,  -2- null )保证在短期内 用户恶意的请求 从redis 缓存中加载数据

击穿:【 单个热点数据 key 在 失效的瞬间 】

1、热点数据设置为永远不过期

2、加互斥锁 --- mysql --- redis

6. Redis 缓存淘汰策略

在Redis中,内存的大小是有限的,所以为了防止内存饱和,需要实现某种键淘汰策略。主要有两种方法,一种是当Redis内存不足时所采用的内存释放策略。另一种是对过期key进行删除的策略,也可以在某种程度上释放内存。

(1)有设置过期时间数据

过期时间快到的数据,优先删除

volatile-lru : 最近最少被使用,优先删除

volatile-lfu : 使用次数最少的,优先删除

volatile-random : 随机删除

(2) 全部所有数据

LRU : 最近最少被使用,优先删除

LFU : 使用次数最少的,优先删除

Random : 随机删除

(3) 默认值:不删除

7. Redis与数据库一致性方案


存在的问题:Redis 缓存的数据值  ≠  MySQL数据库中的值

第 1 种:延时双删策略   【不推荐】

1、先进行缓存清除,    // redis.del(key);

2、再执行update数据库 //db.update(data);

3、最后(延迟N秒)    //Thread.sleep(100);

4、再执行删除缓存。   //redis.del(key);

优点: 操作比较简单,一定程度可以保证缓存和db 数据一致性;

缺点: 在休眠时间内数据存在不一致,而且又增加了写请求的耗时。

第 2 种:基于binlog异步更新

逻辑思路:启动Canal客户端获取binlog日志详情信息–> 逻辑判断–>缓存

以mysql为例 可以使用阿里的canal

(1)canal将binlog日志采集发送到MQ队列里面

(2) 然后编写一个简单的缓存删除消息者订阅binlog日志

(3) 根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性

8. Redis分布式锁

(1)什么是分布式锁

分布式锁,即分布式系统中的锁。在单体应用中我们通过锁解决的是控制共享资源访问的问题,而分布式锁,就是解决了分布式系统中控制共享资源访问的问题。与单体应用不同的是,分布式系统中竞争共享资源的最小粒度从线程升级成了进程

(2)分布式锁的实现方式

  • 基于数据库实现分布式锁
  • 基于Zookeeper实现分布式锁
  • 基于reids实现分布式锁

mysql ----> 行锁 + 乐观锁。获取锁:select update ;执行业务;释放锁:本地事物提交。

Zookeeper -----> 公平锁。获取锁:创建节点(临时顺序);执行业务 ;释放锁:关闭连接,临时顺序节点自动删除。

redis --------> 加锁 setnx orderlock A expire 设置过期时间; 释放锁:del orderlock。

Redis分布式锁实现步骤:

//1. 获取锁 - setnx lock uuid EX 3000 NX
Boolean flag = redisTemplate.opsForValue().setIfAbsent("lock",uuid,3, TimeUnit.SECONDS);
// 中间写业务....
//最后使用Lua脚本释放锁,保证释放锁操作的原子性
 Long execute = (Long) redisTemplate.execute(script, Arrays.asList(key),uuid);

分析:【重点】

1、 所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 `setnx key value`,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

2、在使用 `set key value nx px xxx` 命令时,value 最好是随机字符串UUID,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(套娃)

3、尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁.... ,因此需要配合 Lua 脚本释放锁。

9. Redis持久化机制

(1)什么是持久化机制

Redis是一个基于内存的数据库,所有的数据都存放在内存中,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制

Redis的持久化机制有两种,第一种是RDB快照,第二种是AOF日志

(2) RDB持久化

RDB持久化是指在指定的时间间隔内将内存中的数据集以快照的方式写入磁盘,并保存到一个名为dump.rdb的二进制文件中,也是默认的持久化方式,它恢复时是将快照文件从磁盘直接读到内存里。

(3)AOF持久化

Redis执行过的每个写操作以日志的形式记录下来(读操作不记录),只许追加文件但不可以改写文件(appendonly.aof文件)。redis启动的时候会读取该文件进行数据恢复,根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。

(4)RDB快照和AOF日志的区别

RDB快照是一次全量备份,AOF是连续的增量备份。

RDB快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本  。

RDB------>内存中数据集的快照,默认开启,恢复速度快,数据容易丢失

AOF------>操作日志,默认关闭,恢复速度慢,安全

SpringCloud(Alibaba)--开篇

1. 什么是微服务?

微服务是一种分布式系统架构风格,它的核心理念是将传统的单一应用开发为一组微型服务,每个服务运行在独立的进程中,服务之间采用轻量级通信机制进行相互调用。

传统单体应用存在的弊端:

  • 所有的模块全都耦合在一块,代码量大,维护困难
  • 都共用一个数据库,存储方式比较单一
  • 所有的模块开发所使用的技术一样

微服务优势:微服务的目的是有效的拆分应用,实现敏捷开发和部署

  • 每个模块就相当于一个单独的项目,代码量明显减少,遇到问题也相对来说比较好解决。
  • 每个模块都可以使用不同的存储方式(比如有的用redis,有的用mysql等),数据库也是单个模块对应自己的数据库。
  • 每个模块都可以使用不同的开发技术,开发模式更灵活

2. 什么是Spring Cloud&&Alibaba?

Spring Cloud是Spring开源组织下的一个子项目,提供了一系列用于实现分布式微服务系统的工具集,帮助开发者快速构建微服务应用。

Spring Cloud Alibaba是Spring Cloud的子项目;包含微服务开发必备组件;基于和符合Spring Cloud标准的阿里的微服务解决方案。

3.Nacos

英文全称Dynamic Naming and Configuration Service,Na为naming/nameServer即注册中心,co为configuration即注册中心,service是指该注册/配置中心都是以服务为核心

(1)注册中心

服务提供者、服务消费者、服务发现组件这三者之间的关系大致如下

1、微服务在启动时,将自己的网络地址等信息注册到服务发现组件(nacos server)中,服务发现组件会存储这些信息。

2、各个微服务与服务发现组件使用一定机制通信(例如在一定的时间内发送心跳包)。服务发现组件若发现与某微服务实例通信正常则保持注册状态(up在线状态)、若长时间无法与某微服务实例通信,就会自动注销(即:删除)该实例。

3、服务消费者可从服务发现组件查询服务提供者的网络地址,并使用该地址调用服务提供者的接口。

4、当微服务网络地址发生变更(例如实例增减或者IP端口发生变化等)时,会重新注册到服务发现组件。

4.Feign

Feign是一个http请求调用的轻量级框架,可以以Java接口注解的方式调用Http请求。Spring Cloud引入 Feign并且集成了Ribbon实现客户端负载均衡调

Feign远程调用,核心就是通过一系列的封装和处理,将以JAVA注解的方式定义的远程调用API接口,最终转换成HTTP的请求形式,然后将HTTP的请求的响应结果,解码成JAVA Bean,放回给调用者。

@FeignClient(value="article-server", path = "/article")
public interface IFeignArticleController {
    /**
     * Feign接口-根据标签ids查询对应的标签信息
     * @return
     */   
    @GetMapping("/api/feign/label/list/{ids}")
    List<Label> getLabelListById(@PathVariable("ids") List<String> labelIds)

5. Gateway服务网关

什么是服务网关
网关是整个微服务API请求的入口,负责拦截所有请求,再分发到服务上去。
可以实现日志拦截、权限控制、解决跨域、限流、熔断、负载均衡,隐藏服务端的ip,黑名单与白名单拦截、授权等。

6.服务容错-Sentinel

(1)  什么是雪崩效应?

1、将业务拆分为不同服务,服务与服务之间可以相互调用,但是由于网络原因或者自身的原因,服务并不能保证服务的100%可用,如果单个服务出现问题,调用这个服务就会出现网络延迟,此时若有大量的网络涌入,会形成任务堆积,最终导致服务瘫痪。

2、在分布式系统中,由于网络原因或自身的原因,服务一般无法保证 100% 可用。如果一个服务出现了问题,调用这个服务就会出现线程阻塞的情况,此时若有大量的请求涌入,就会出现多条线程阻塞等待(在java程序里面,一次请求对应线程对应的是服务器资源–>CPU、内存),进而导致服务瘫痪。

3、由于服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果,这就是服务故障的 “雪崩效应” 。

(2) Sentinel 作用

sentinel是轻量级的流量控制、熔断降级Java库(容错的库)

(3) 常见容错方案【避免雪崩效应】

超时模式–限流模式–仓壁模式–断路器模式–降级模式

超时【思想:只要释放够快服务就不容易那么死了】
为每次请求设置一个最大响应时间(超时时间,如1秒),如果超过这个时间,不管这次请求是否成功,就断开这次请求,释放掉线程。只要线程释放速度够快,被请求的服务就不那么容易被拖死。

限流【思想:只有一碗的饭量,给再多也只是吃一碗】
高并发的系统存在大量的线程阻塞,若经过评估被请求B服务的实例,最大能够承载的QPS是1000,那么久可以为B服务设置一个适合的限流的值(如800QPS),只要某一个实例达到设置的阈值,再有流量(请求)进来就会被直接拒绝。实现了自己的保护

仓壁/隔离模式【思想:不把鸡蛋放一个篮子里面,各有各的线程池】
如将船分为若干个船舱,船舱与船舱之间用钢板焊死,使船舱达到隔离,即使某个船舱进水也不会影响其它的船舱。
常见的隔离方式有:线程池隔离和信号量隔离。
线程池隔离:如为A、B两个controller设置独立的线程池(coreSize=10),他们之间就是用线程池这个钢板焊死了,而A controller对应的API调不通就像是船舱进水了,A的船舱进水与B的船舱没有任何关系。这就是仓壁模式。

断路器模式【监控+开关】
(1)全开状态【服务调用失败达到一定次数】
一定时间内,服务调用失败达到一定次数,且多次检测无恢复迹象,断路器全开
(2)半开状态【服务有恢复迹象】
短时间内,有恢复迹象,断路器会将部分请求发给该服务,断路器半开
(3)关闭状态【服务正常状态】
当服务处于正常状态,能正常调用,断路器关闭

降级
降级其实就是为服务提供一个托底方案,一旦服务无法正常调用,就使用托底方案。

目录
相关文章
|
5天前
|
存储 缓存 Oracle
Java I/O流面试之道
NIO的出现在于提高IO的速度,它相比传统的输入/输出流速度更快。NIO通过管道Channel和缓冲器Buffer来处理数据,可以把管道当成一个矿藏,缓冲器就是矿藏里的卡车。程序通过管道里的缓冲器进行数据交互,而不直接处理数据。程序要么从缓冲器获取数据,要么输入数据到缓冲器。
Java I/O流面试之道
|
2天前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
20 4
|
26天前
|
存储 安全 算法
Java面试题之Java集合面试题 50道(带答案)
这篇文章提供了50道Java集合框架的面试题及其答案,涵盖了集合的基础知识、底层数据结构、不同集合类的特点和用法,以及一些高级主题如并发集合的使用。
72 1
Java面试题之Java集合面试题 50道(带答案)
|
14天前
|
存储 Java 程序员
Java面试加分点!一文读懂HashMap底层实现与扩容机制
本文详细解析了Java中经典的HashMap数据结构,包括其底层实现、扩容机制、put和查找过程、哈希函数以及JDK 1.7与1.8的差异。通过数组、链表和红黑树的组合,HashMap实现了高效的键值对存储与检索。文章还介绍了HashMap在不同版本中的优化,帮助读者更好地理解和应用这一重要工具。
35 5
|
13天前
|
存储 Java
[Java]面试官:你对异常处理了解多少,例如,finally中可以有return吗?
本文介绍了Java中`try...catch...finally`语句的使用细节及返回值问题,并探讨了JDK1.7引入的`try...with...resources`新特性,强调了异常处理机制及资源自动关闭的优势。
16 1
|
22天前
|
Java 程序员
Java 面试高频考点:static 和 final 深度剖析
本文介绍了 Java 中的 `static` 和 `final` 关键字。`static` 修饰的属性和方法属于类而非对象,所有实例共享;`final` 用于变量、方法和类,确保其不可修改或继承。两者结合可用于定义常量。文章通过具体示例详细解析了它们的用法和应用场景。
23 3
|
2月前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析
本文是“Java学习路线”专栏的导航文章,目标是为Java初学者和初中高级工程师提供一套完整的Java学习路线。
368 37
|
25天前
|
Java
Java面试题之cpu占用率100%,进行定位和解决
这篇文章介绍了如何定位和解决Java服务中CPU占用率过高的问题,包括使用top命令找到高CPU占用的进程和线程,以及使用jstack工具获取堆栈信息来确定问题代码位置的步骤。
68 0
Java面试题之cpu占用率100%,进行定位和解决
|
29天前
|
存储 安全 Java
java基础面试题
java基础面试题
27 2
|
29天前
|
缓存 NoSQL Java
Java中redis面试题
Java中redis面试题
33 1