JAVA高级面试题——2019

文章目录

  • JAVA基础
    • JVM
      • 类加载过程
      • 类加载机制
      • 内存模型
      • 内存分配与内存回收(GC)
      • JVM线上调优
      • JVM调优参数简介、调优目标及调优经验
      • JVM内存设置多大合适?Xmx和Xmn如何设置?
      • JVM总结(三)Minor GC、Major GC和Full GC
    • 集合
      • 实现 Java list,要求实现 list 的 get(), add(), remove() 三个功能函数,不能直接使用 ArrayList、LinkedList 等 Java 自带高级类(阿里面试题)
      • 数据结构与算法精选面试题
      • 自己实现一个arraylsit
      • Hash是什么?HashCode是什么?Hash表是什么?为什么要用HashCode?
      • hashcode详解
      • HashMap是如何存储的?
      • HashMap碰撞原理,jdk是如何解决碰撞问题的?
      • HashMap的key重复,那么value会被覆盖吗?
      • HashMap如何实现相同key存入数据后不被覆盖?
      • 什么时候需要重写HashCode、equals?为什么重写equals方法时必须重写hashcode方法?
      • Jdk1.6,JDK1.7,jdk1.8中HashMap区别
    • 多线程
      • 线程池的原理?
      • Synchronized原理
      • Synchronized与lock区别?
      • volatile原理
      • Java中常用的锁机制有那些?
  • 数据库
    • Mysql数据库的优化技术
    • Sql优化
    • Mysql如何做高可用?
    • Mysql存储原理?
    • Mysql数据库隔离级别?
    • Mysql如何实现ACID?
    • Mysql千万级优化?
    • 单表优化
    • 索引
  • 框架
    • Spring
      • SpringIOC
      • SpringAOP
      • SpringMVC
      • Spring中运用了那些设计模式
      • Spring中的Bean是线程安全的吗
      • Spring中的Bean的作用域
      • Spring中的Bean的生命周期
      • Spring事务的传播行为
      • Spring事务默认抛出什么异常
    • SpringBoot
      • SpringBoot实现原理
      • RestController与Controller区别
    • Mybatis
      • Mybatis缓存怎么用
      • Mybatis中运用了那些设计模式
      • 静态代理、JDK动态代理与cglib动态代理区别
  • 分布式架构
    • SpringCloud
    • Double
      • Double原理
      • Dubbo RPC原理解读
      • Double是如何实现负载均衡的
      • Double如何实现异步调用
    • SpringCloud与Double区别?
    • 分布式事物实现原理
    • 分布式缓存实现原理
    • 分布式锁实现原理
    • 分布式如何解决数据一致性
  • 中间件
    • Redis
      • Redis如何做高可用?
      • Redis如何实现队列?
      • Redis如何做持久化?
      • Redis做持久化为什么会降低性能?
      • Redis哨兵机制?
      • Redis分片
      • Redis单线程为什么快?
      • Redis与Nginx、Memcached区别?
    • RabbitMq
      • RabbitMq如何做高可用?
      • RabbitMq死信队列原理,应用场景?
      • RabbitMq如何保证消费顺序?
      • RabbitMq网络拓扑?
      • RabbitMq如何解决消息丢失?
      • RabbitMq多客户端下如何解决重复消费?
    • Nginx
    • Zookeeper
  • 性能调优
    • JVM调优
    • tomcat调优
    • Nginx调优
    • 数据库调优

JAVA基础

JVM

类加载过程

当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载,连接,初始化三步来实现这个类进行初始化。

  1. 加载

加载,是指Java虚拟机查找字节流(查找.class文件),并且根据字节流创建java.lang.Class对象的过程。这个过程,将类的.class文件中的二进制数据读入内存,放在运行时区域的方法区内。然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。

类加载阶段:

(1)Java虚拟机将.class文件读入内存,并为之创建一个Class对象。

(2)任何类被使用时系统都会为其创建一个且仅有一个Class对象。

(3)这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等。

Student类加载过程图示:
JAVA高级面试题——2019_第1张图片

  1. 链接

链接包括验证、准备以及解析三个阶段。

(1)验证阶段。主要的目的是确保被加载的类(.class文件的字节流)满足Java虚拟机规范,不会造成安全错误。

(2)准备阶段。负责为类的静态成员分配内存,并设置默认初始值。

(3)解析阶段。将类的二进制数据中的符号引用替换为直接引用。

说明:

符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。

直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。

举个例子来说,现在调用方法hello(),这个方法的地址是0xaabbccdd,那么hello就是符号引用,0xaabbccdd就是直接引用。

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

  1. 初始化

初始化,则是为标记为常量值的字段赋值的过程。换句话说,只对static修饰的变量或语句块进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

  1. 小结

类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载(垃圾回收)。

  1. 附录

常见问题:在自己的项目里新建一个java.lang包,里面新建了一个String类,能代替系统String吗?

不能,因为根据类加载的双亲委派机制,会将请求转发给父类加载器,父类加载器发现冲突了String就不会加载了。

类加载机制

类加载器简单来说是用来加载 Java 类到 Java 虚拟机中的。Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例。每个这样的实例用来表示一个 Java 类。通过此实例的 newInstance()方法就可以创建出该类的一个对象。

想要真正深入理解Java类加载机制,就要弄懂三个问题:类什么时候加载、类加载的过程是什么、用什么加载。所以本文分为三部分分别介绍Java类加载的时机、类加载的过程、加载器。

一、Java类加载的时机

1.1 类加载的生命周期

类加载的生命周期是从类被加载到内存开始,直到卸载出内存为止的。整个生命周期分为7个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中,验证、准备、解析三部分统称为连接。具体步骤如下图所示:

JAVA高级面试题——2019_第2张图片

下面简单介绍下类加载器所执行的生命周期的过程。

(1) 装载:查找和导入Class文件;

(2) 链接:把类的二进制数据合并到JRE中;

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

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

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

(3) 初始化:对类的静态变量,静态代码块执行初始化操作。

1.2 类加载的时机

类加载的时机Java虚拟机规范中并没有强制规定,但是对于初始化阶段,有5种场景必须立即执行初始化,也被称为主动引用。

(1) 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

(2) 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

(3) 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

(5)当使用JDK 1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且该方法句柄所对应的类没有初始化过,则先触发初始化。

二、Java类加载的过程
JAVA高级面试题——2019_第3张图片

类加载的全过程分为7个阶段,但是主要的过程是加载、验证、准备、解析、初始化这5个阶段。

2.1 加载

在加载阶段,虚拟机需要完成3件事情:

(1) 通过一个类的全限定名来获取定义此类的二进制字节流;

(2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;

(3) 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

2.2 验证

验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。整体来看,验证阶段大致分为4个验证动作。

(1)文件格式验证

第一阶段是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。该阶段是基于二进制字节流验证的,只有通过了这个阶段的验证,字节流才会进入内存的方法去中存储,后面的3个验证都是基于方法区的存储结构进行的。

这一阶段可能的验证点:

a.是否以魔数开头;

b.主、次版本号是否在当前虚拟机处理范围内;

c.常量池的常量数据类型是否被支持;

。。。

(2)元数据验证

元数据验证是对字节码描述信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个阶段可能的验证点:

a.是否有父类;

b.是否继承了不被允许继承的类;

c.如果该类不是抽象类,是否实现了其父类或接口要求实现的所有方法;

。。。

(3)字节码验证

字节码验证的主要目的是通过数据流和控制流分析,确定程序语义的合法性和逻辑性。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事情。这个阶段可能的验证点:

a.保证任何时候操作数栈的数据类型与指令代码序列的一致性;

b.跳转指令不会跳转到方法体以外的字节码指令上;

。。。

(4)符号引用验证

符号引用验证的主要目的是保证解析动作能正常执行,如果无法通过符号引用验证,则会抛出异常。这个阶段可能的验证点:

a.符号引用的类、字段、方法的访问性(public、private等)是否可被当前类访问;

b.指定类是否存在符合方法的字段描述符;

。。。

2.3 准备

准备阶段是正式为类变量分配并设置类变量初始值的阶段,这些内存都将在方法区中进行分配,需要说明的是:这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中;这里所说的初始值“通常情况”是数据类型的零值,例如:

public static int value = 1;

value在准备阶段过后的初始值为0而不是1,而把value赋值的putstatic指令将在初始化阶段才会被执行。

特殊情况:

public static final int value = 1;//此时准备value赋值为1

2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。直接引用是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存有关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用不尽相同。

2.5 初始化

初始化阶段是类加载过程的最后一步,到了该阶段才真正开始执行类定义的Java程序代码,根据程序员通过代码定制的主观计划去初始化类变量和其他资源,是执行类构造器初始化方法的过程。

三、类加载器

类加载器大致可以分为以下3部分:

(1) 启动类加载器: 将存放于\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。

(2) 扩展类加载器 : 将\lib\ext目录下的,或者被java.ext.dirs系统变量所指定的路径中的所有类库加载。开发者可以直接使用扩展类加载器。

(3) 应用程序类加载器: 负责加载用户类路径(ClassPath)上所指定的类库,开发者可直接使用。

我们的应用程序都是由这三种类加载器相互配合加载的。它们的关系如下图所示,称之为双亲委派模型。

JAVA高级面试题——2019_第4张图片

工作过程:如果一个类加载器接收到了类加载的请求,它首先把这个请求委托给他的父类加载器去完成,每个层次的类加载器都是如此,因此所有的加载请求都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它在搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

好处:java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都会委派给启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果用户自己写了一个名为java.lang.Object的类,并放在程序的Classpath中,那系统中将会出现多个不同的Object类,java类型体系中最基础的行为也无法保证,应用程序也会变得一片混乱。

双亲委派模型实现起来其实很简单,以下是实现代码,通过以下代码,可以对JVM采用的双亲委派类加载机制有了更感性的认识。

//类加载过程

 public Class loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

 protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException

    {
		    // 首先判断该类型是否已经被加载
		    Class c = findLoadedClass(name);
		    if (c == null) {
		    //没有被加载,就委托给父类加载器或者委派给启动类加载器加载

    try {
		    if (parent != null) {
		    // 如果存在父类加载器,就委派给父类加载器加载
		    c = parent.loadClass(name, false);
		    } else {
		        // 如果不存在父类加载器,就检查是否是由启动类加载器加载的类
		        c = findBootstrapClassOrNull(name);
		    }
        } catch (ClassNotFoundException e) {
        }

      if (c == null) {
        // 如果父类加载器和启动类加载器都不能完成加载任务,调用自身的加载工程
        c = findClass(name);
    }

}

    if (resolve) {
        resolveClass(c);
    }

return c;

 }

总结:

本文从Java类加载的时机、类加载的过程以及类加载的方式三方面对Java类加载机制进行了浅析,希望通过阅读本文可以对Java类加载机制有个大致的了解。

内存模型

JAVA高级面试题——2019_第5张图片
JAVA高级面试题——2019_第6张图片
由上图可以清楚的看到JVM的内存空间分为3大部分:

堆内存
方法区
栈内存
其中栈内存可以再细分为java虚拟机栈和本地方法栈,堆内存可以划分为新生代和老年代,新生代中还可以再次划分为Eden区、From Survivor区和To Survivor区。

其中一部分是线程共享的,包括 Java 堆和方法区;另一部分是线程私有的,包括虚拟机栈和本地方法栈,以及程序计数器这一小部分内存。

堆内存(Heap)

对于大多数应用来说,Java 堆(Java Heap)是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

堆内存是所有线程共有的,可以分为两个部分:年轻代老年代

下图中的Perm代表的是永久代,但是注意永久代并不属于堆内存中的一部分,同时jdk1.8之后永久代已经被移除。
JAVA高级面试题——2019_第7张图片

新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 )

默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。

方法区(Method Area)

方法区也称"永久代",它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。

在JDK8之前的HotSpot JVM,存放这些”永久的”的区域叫做“永久代(permanent generation)”。永久代是一片连续的堆空间,在JVM启动之前通过在命令行设置参数-XX:MaxPermSize来设定永久代最大可分配的内存空间,默认大小是64M(64位JVM默认是85M)。

随着JDK8的到来,JVM不再有 永久代(PermGen)。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory。

方法区或永生代相关设置

-XX:PermSize=64MB 最小尺寸,初始分配
-XX:MaxPermSize=256MB 最大允许分配尺寸,按需分配
XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled 设置垃圾不回收
默认大小
-server选项下默认MaxPermSize为64m
-client选项下默认MaxPermSize为32m

虚拟机栈(JVM Stack)

描述的是java方法执行的内存模型:每个方法被执行的时候都会创建一个"栈帧",用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  1. Java虚拟机栈(JVM Stack)
    2.1. 定义
    相对于基于寄存器的运行环境来说,JVM是基于栈结构的运行环境
    栈结构移植性更好,可控性更强
    JVM中的虚拟机栈是描述Java方法执行的内存区域,它是线程私有的

栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程
在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧
正在执行的方法称为当前方法
栈帧是方法运行的基本结构

在执行引擎运行时,所有指令都只能针对当前栈帧进行操作
StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中
JVM能够横扫千军,虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵
JAVA高级面试题——2019_第8张图片

虚拟机栈通过压/出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上
在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定
栈帧在整个JVM体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等

局部变量表
存放方法参数和局部变量
相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化
如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量
字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内
操作栈
操作栈是一个初始状态为空的桶式结构栈
在方法执行过程中,会有各种指令往栈中写入和提取信息
JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈
字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的stack属性中
下面用一段简单的代码说明操作栈与局部变量表的交互
JAVA高级面试题——2019_第9张图片

详细的字节码操作顺序如下:
JAVA高级面试题——2019_第10张图片
第1处说明:局部变量表就像个中药柜,里面有很多抽屉,依次编号为0, 1, 2,3,.,. n
字节码指令istore_ 1就是打开1号抽屉,把栈顶中的数13存进去
栈是一个很深的竖桶,任何时候只能对桶口元素进行操作,所以数据只能在栈顶进行存取
某些指令可以直接在抽屉里进行,比如inc指令,直接对抽屉里的数值进行+1操作
程序员面试过程中,常见的i++和++i的区别,可以从字节码上对比出来

JAVA高级面试题——2019_第11张图片
iload_ 1从局部变量表的第1号抽屉里取出一个数,压入栈顶,下一步直接在抽屉里实现+1的操作,而这个操作对栈顶元素的值没有影响
所以istore_ 2只是把栈顶元素赋值给a
表格右列,先在第1号抽屉里执行+1操作,然后通过iload_ 1 把第1号抽屉里的数压入栈顶,所以istore_ 2存入的是+1之后的值
这里延伸一个信息,i++并非原子操作。即使通过volatile关键字进行修饰,多个线程同时写的话,也会产生数据互相覆盖的问题.

动态连接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接
方法返回地址
方法执行时有两种退出情况
正常退出
正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等
异常退出
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧

退出可能有三种方式:

返回值压入,上层调用栈帧
异常信息抛给能够处理的栈帧
PC计数器指向方法调用后的下一条指令
Java虚拟机栈是描述Java方法运行过程的内存模型

Java虚拟机栈会为每一个即将运行的Java方法创建“栈帧”
用于存储该方法在运行过程中所需要的一些信息

局部变量表
存放基本数据类型变量、引用类型的变量、returnAddress类型的变量
操作数栈
动态链接
当前方法的常量池指针
当前方法的返回地址
方法出口等信息
每一个方法从被调用到执行完成的过程,都对应着一个个栈帧在JVM栈中的入栈和出栈过程

注意:人们常说,Java的内存空间分为“栈”和“堆”,栈中存放局部变量,堆中存放对象。
这句话不完全正确!这里的“堆”可以这么理解,但这里的“栈”就是现在讲的虚拟机栈,或者说Java虚拟机栈中的局部变量表部分.
真正的Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息.

2.2. 特点
局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建.
而且表的大小在编译期就确定,在创建的时候只需分配事先规定好的大小即可.
在方法运行过程中,表的大小不会改变

Java虚拟机栈会出现两种异常

StackOverFlowError
若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求的栈深度大于虚拟机允许的最大深度时(但内存空间可能还有很多),就抛出此异常
OutOfMemoryError
若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常
Java虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡.

本地方法栈(Native Stack)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

程序计数器(PC Register)

程序计数器是用于标识当前线程执行的字节码文件的行号指示器。多线程情况下,每个线程都具有各自独立的程序计数器,所以该区域是非线程共享的内存区域。

当执行java方法时候,计数器中保存的是字节码文件的行号;当执行Native方法时,计数器的值为空。

直接内存

直接内存并不是虚拟机内存的一部分,也不是Java虚拟机规范中定义的内存区域。jdk1.4中新加入的NIO,引入了通道与缓冲区的IO方式,它可以调用Native方法直接分配堆外内存,这个堆外内存就是本机内存,不会影响到堆内存的大小。

JVM内存参数设置

-Xms设置堆的最小空间大小。
-Xmx设置堆的最大空间大小。
-Xmn:设置年轻代大小
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小
-XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
-XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。
典型JVM参数配置参考:

java-Xmx3550m-Xms3550m-Xmn2g-Xss128k
-XX:ParallelGCThreads=20
-XX:+UseConcMarkSweepGC-XX:+UseParNewGC
-Xmx3550m:设置JVM最大可用内存为3550M。

-Xms3550m:设置JVM促使内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。

-Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小+年老代大小+持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,官方推荐配置为整个堆的3/8。

-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大 小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000 左右。

内存分配与内存回收(GC)

java虚拟机的内存分配与回收机制
  分为4个方面来介绍内存分配与回收,分别是内存是如何分配的、哪些内存需要回收、在什么情况下执行回收、如何监控和优化GC机制。

java GC(Garbage Collction)垃圾回收机制,是java与C/C++的主要区别之一。通过对jvm中内存进行标记,自主回收一些无用的内存。目前使用的最多的是sun公司jdk中的HotSpot,所以本文也以该jvm作为介绍的根本。

1.Java内存区域

在java运行时的数据取里,由jvm管理的内存区域分为多个部分:
JAVA高级面试题——2019_第12张图片

程序计数器(program counter register):程序计数器是一个比较校的内存单元,用来表示当前程序运行哪里的一个指示器。由于每个线程都由自己的执行顺序,所以程序计数器是线程私有的,每个线程都要由一个自己的程序计数器来指示自己(线程)下一步要执行哪条指令。

如果程序执行的是一个java方法,那么计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地方法(native方法),那么计数器的值为Undefined。由于程序计数器记录的只是当前指令地址,所以不存在内存泄漏的情况,也是jvm内存区域中唯一一个没有OOME(out of memory error)定义的区域。

虚拟机栈(JVM stack):当线程的每个方法在执行的时候都会创建一个栈帧(Stack Frame)用来存储方法中的局部变量、方法出口等,同时会将这个栈帧放入JVM栈中,方法调用完成时,这个栈帧出栈。每个线程都要一个自己的虚拟机栈来保存自己的方法调用时候的数据,因此虚拟机栈也是线程私有的。

虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,抛出StackOverFlowError,不过虚拟机基本上都允许动态扩展虚拟机栈的大小。这样的话线程可以一直申请栈,直到内存不足的时候,会抛出OOME(out of memory error)内存溢出。

本地方法栈(Native Method Stack):本地方法栈与虚拟机栈类似,只是本地方法栈存放的栈帧是在native方法调用的时候产生的。有的虚拟机中会将本地方法栈和虚拟栈放在一起,因此本地方法栈也是线程私有的。

堆(Heap):堆是java GC机制中最重要的区域。堆是为了放置“对象的实例”,对象都是在堆区上分配内存的,堆在逻辑上连续,在物理上不一定连续。所有的线程共用一个堆,堆的大小是可扩展的,如果在执行GC之后,仍没有足够的内存可以分配且堆大小不可再扩展,将会抛出OOME。

方法区(Method Area):又叫静态区,用于存储类的信息、常量池等,逻辑上是堆的一部分,是各个线程共享的区域,为了与堆区分,又叫非堆。在永久代还存在时,方法区被用作永久代。方法区可以选择是否开启垃圾回收。jvm内存不足时会抛出OOME。

直接内存(Direct Memory):直接内存指的是非jvm管理的内存,是机器剩余的内存。用基于通道(Channel)和缓冲区(Buffer)的方式来进行内存分配,用存储在JVM中的DirectByteBuffer来引用,当机器本身内存不足时,也会抛出OOME。

举例说明:Object obj = new Object();

obj表示一个本地引用,存储在jvm栈的本地变量表中,new Object()作为一个对象放在堆中,Object类的类型信息(接口,方法,对象类型等)放在堆中,而这些类型信息的地址放在方法区中。

这里需要知道如何通过引用访问到具体对象,也就是通过obj引用如何找到new出来的这个Object()对象,主要有两种方法,通过句柄和通过直接指针访问。
JAVA高级面试题——2019_第13张图片
  通过句柄:

在java堆中会专门有一块区域被划分为句柄池,一个引用的背后是一个对象实例数据(java堆中)的指针和对象类型信息(方法区中)的指针,而这两个指针都是在java堆上的。这种方法是优势是较为稳定,但是速度不是很快。
JAVA高级面试题——2019_第14张图片
  通过直接指针:

一个引用背后是一个对象的实例数据,这个实例数据里面包含了“到对象类型信息的指针”。这种方式的优势是速度快,在HotSpot中用的就是这种方式。

2.内存是如何分配和回收的

内存分配主要是在堆上的分配,如前面new出来的对象,放在堆上,但是现代技术也支持在栈上分配,较为少见,本文不考虑。分配内存与回收内存的标准是八个字:分代分配,分代回收。那么这个代是什么呢?

jvm中将对象根据存活的时间划分为三代:年轻代(Young Generation)、年老代(Old Generation)和永久代(Permannent Generation)。在jdk1.8中已经不再使用永久代,因此这里不再介绍。

JAVA高级面试题——2019_第15张图片

年轻代:又叫新生代,所有新生成的对象都是先放在年轻代。年轻代分三个区,一个Eden区,两个Survivor区,一个叫From,一个叫To(这个名字是动态变化的)。当Eden中满时,执行Minor GC将消亡的对象清理掉,仍存活的对象将被复制到Survivor中的From区,清空Eden。当这个From区满的时候,仍存活的对象将被复制到To区,清空From区,并且原From区变为To区,原To区变为From区,这样的目的是保证To区一直为空。当From区满无对象可清理或者From-To区交换的次数超过设定(HotSpot默认为15,通过-XX:MaxTenuringThreashold控制)的时候,仍存活的对象进入老年代。年轻代中Eden和Servivor的比例通过-XX:SerivorRation参数来配置,默认为8,也就时说Eden:From:To=8:1:1。年轻代的回收方式叫做Minor GC,又叫停止-复制清理法。这种方法在回收的时候,需要暂停其他所有线程的执行,导致效率很低,现在虽然有优化,但是仅仅是将停止的时间变短,并没有彻底取消这个停止。

年老代:年老代的空间较大,当年老代内存不足时,将执行Major GC也叫Full GC。如果对象比较大,可能会直接分配到老年代上而不经过年轻代。用-XX:pertenureSizeThreashold来设定这个值,大于这个的对象会直接分配到老年代上。

3.垃圾收集器

在GC机制中,起作用的是垃圾收集器。HotSpot1.6中使用的垃圾收集器如下(有连线表示有联系):
JAVA高级面试题——2019_第16张图片

Serial收集器:新生代(年轻代)收集器,使用停止-复制算法,使用一个线程进行GC,其他工作线程暂停。

ParNew收起:新生代收集器,使用停止-复制算法,Serial收集器的多线程版,用多个线程进行GC,其他工作线程暂停,关注缩短垃圾收集时间。

Parallel Scavenge收集器:新生代收集器,使用停止-复制算法,关注CPU吞吐量,即运行用户代码的时间/总时间。

Serial Old收集器:年老代收集器,单线程收集器,使用标记-整理算法(整理的方法包括sweep清理和compact压缩,标记-清理是先标记需要回收的对象,在标记完成后统一清楚标记的对象,这样清理之后空闲的内存是不连续的;标记-压缩是先标记需要回收的对象,把存活的对象都向一端移动,然后直接清理掉端边界以外的内存,这样清理之后空闲的内存是连续的)。

Parallel Old收集器:老年代收集器,多线程收集器,使用标记-整理算法(整理的方法包括summary汇总和compact压缩,标记-压缩与Serial Old一样,标记-汇总是将幸存的对象复制到预先准备好的区域,再清理之前的对象)。

CMS(Concurrent Mark Sweep)收集器:老年老代收集器,多线程收集器,关注最短回收时间停顿,使用标记-清除算法,用户线程可以和GC线程同时工作。

G1收集器:JDK1.7中发布,使用较少,不作介绍。

Java GC是一个非常复杂的机制,想要详细说清楚他需要很多时间,如有错误恳请指正。

JVM线上调优

Azkaban执行多次调度任务之后,就会进入preparation 等待状态,整个服务器就卡住。修改初始堆内存与最大堆内存为2G,
一般和Xmx配置成一样以避免每次gc后JVM重新分配内存。

之后还是不行。后面通过jstat定位到fullGC时间等待太长,通过jstack定位线程问题,最后发现是每次打包etl脚本,执行task之前,Azkaban会进行一个遍历包含etl脚本的压缩文件。直接读取文件到内存之中,文件过大,直接造成频繁fullGC。最后通过执行完task,清空压缩包的方式。解决了问题

一个性能较好的web服务器jvm参数配置:

-server//服务器模式
-Xmx2g //JVM最大允许分配的堆内存,按需分配
-Xms2g //JVM初始分配的堆内存,一般和Xmx配置成一样以避免每次gc后JVM重新分配内存。
-Xmn256m //年轻代内存大小,整个JVM内存=年轻代 + 年老代 + 持久代
-XX:PermSize=128m //持久代内存大小
-Xss256k //设置每个线程的堆栈大小
-XX:+DisableExplicitGC //忽略手动调用GC, System.gc()的调用就会变成一个空调用,完全不触发GC
-XX:+UseConcMarkSweepGC //并发标记清除(CMS)收集器
-XX:+CMSParallelRemarkEnabled //降低标记停顿
-XX:+UseCMSCompactAtFullCollection //在FULL GC的时候对年老代的压缩
-XX:LargePageSizeInBytes=128m //内存页的大小
-XX:+UseFastAccessorMethods //原始类型的快速优化
-XX:+UseCMSInitiatingOccupancyOnly //使用手动定义初始化定义开始CMS收集
-XX:CMSInitiatingOccupancyFraction=70 //使用cms作为垃圾回收使用70%后开始CMS收集

1.jvm调优总结:
https://www.cnblogs.com/andy-zhou/p/5327288.html#_caption_19
2.大型跨境电商JVM调优经历:
https://scholers.iteye.com/blog/2411414
3.一次线上JVM调优实践,FullGC40次/天到10天一次的优化过程:https://blog.csdn.net/cml_blog/article/details/81057966

JVM调优参数简介、调优目标及调优经验

一、JVM调优参数简介

1、 JVM参数简介

-XX 参数被称为不稳定参数,之所以这么叫是因为此类参数的设置很容易引起JVM 性能上的差异,使JVM 存在极大的不稳定性。如果此类参数设置合理将大大提高JVM 的性能及稳定性。

不稳定参数语法规则:

1.布尔类型参数值
-XX:+ '+'表示启用该选项
-XX:- '-'表示关闭该选项
2.数字类型参数值:
-XX:= 给选项设置一个数字类型值,可跟随单位,例如:'m’或’M’表示兆字节;'k’或’K’千字节;'g’或’G’千兆字节。32K与32768是相同大小的。
3.字符串类型参数值:
-XX:= 给选项设置一个字符串类型值,通常用于指定一个文件、路径或一系列命令列表。
例如:-XX:HeapDumpPath=./dump.core

2、 JVM参数示例

配置: -Xmx4g –Xms4g –Xmn1200m –Xss512k -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:PermSize=100m

-XX:MaxPermSize=256m -XX:MaxTenuringThreshold=15

解析:
-Xmx4g:堆内存最大值为4GB。
-Xms4g:初始化堆内存大小为4GB 。
-Xmn1200m:设置年轻代大小为1200MB。增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
-Xss512k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1MB,以前每个线程堆栈大小为256K。应根据应用线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-XX:PermSize=100m:初始化永久代大小为100MB。
-XX:MaxPermSize=256m:设置持久代大小为256MB。
-XX:MaxTenuringThreshold=15:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

二、JVM调优目标

  1. 何时需要做jvm调优?

    1. heap 内存(老年代)持续上涨达到设置的最大内存值;
    2. Full GC 次数频繁;
    3. GC 停顿时间过长(超过1秒);
    4. 应用出现OutOfMemory 等内存异常;
    5. 应用中有使用本地缓存且占用大量内存空间;
    6. 系统吞吐量与响应性能不高或下降。
  2. JVM调优原则

    1.多数的Java应用不需要在服务器上进行JVM优化;

    2.多数导致GC问题的Java应用,都不是因为我们参数设置错误,而是代码问题;

    3.在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合);

    4.减少创建对象的数量;

    5.减少使用全局变量和大对象;

    6.JVM优化是到最后不得已才采用的手段;

    7.在实际使用中,分析GC情况优化代码比优化JVM参数更好;

  3. JVM调优目标

    1. GC低停顿;

    2. GC低频率;

    3. 低内存占用;

    4. 高吞吐量;

JVM调优量化目标(示例):

  1. Heap 内存使用率 <= 70%;

  2. Old generation内存使用率<= 70%;

  3. avgpause <= 1秒; 

  4. Full gc 次数0 或 avg pause interval >= 24小时 ;

  注意:不同应用,其JVM调优量化目标是不一样的。

三、JVM调优经验

1. JVM调优经验总结

JVM调优的一般步骤为:

  • 第1步:分析GC日志及dump文件,判断是否需要优化,确定瓶颈问题点;

  • 第2步:确定JVM调优量化目标;

  • 第3步:确定JVM调优参数(根据历史JVM参数来调整);

  • 第4步:调优一台服务器,对比观察调优前后的差异;

  • 第5步:不断的分析和调整,直到找到合适的JVM参数配置;

  • 第6步:找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

2. JVM调优重要参数解析

注意:不同应用,其JVM最佳稳定参数配置是不一样的。

配置: -server

-Xms12g -Xmx12g -XX:PermSize=500m -XX:MaxPermSize=1000m -Xmn2400m -XX:SurvivorRatio=1 -Xss512k -XX:MaxDirectMemorySize=1G

-XX:+DisableExplicitGC -XX:CompileThreshold=8000 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC

-XX:+UseCompressedOops -XX:CMSInitiatingOccupancyFraction=60 -XX:ConcGCThreads=4

-XX:MaxTenuringThreshold=10 -XX:ParallelGCThreads=8

-XX:+ParallelRefProcEnabled -XX:+CMSClassUnloadingEnabled -XX:+CMSParallelRemarkEnabled

-XX:CMSMaxAbortablePrecleanTime=500 -XX:CMSFullGCsBeforeCompaction=4

XX:+UseCMSInitiatingOccupancyOnly -XX:+UseCMSCompactAtFullCollection

-XX:+HeapDumpOnOutOfMemoryError -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/weblogic/gc/gc_$$.log

重要参数(可调优)解析:

-Xms12g:初始化堆内存大小为12GB。

-Xmx12g:堆内存最大值为12GB 。

-Xmn2400m:新生代大小为2400MB,包括 Eden区与2个Survivor区。

-XX:SurvivorRatio=1:Eden区与一个Survivor区比值为1:1。

-XX:MaxDirectMemorySize=1G:直接内存。报java.lang.OutOfMemoryError: Direct buffer memory 异常可以上调这个值。

-XX:+DisableExplicitGC:禁止运行期显式地调用 System.gc() 来触发fulll GC。

注意: Java RMI的定时GC触发机制可通过配置-Dsun.rmi.dgc.server.gcInterval=86400来控制触发的时间。

-XX:CMSInitiatingOccupancyFraction=60:老年代内存回收阈值,默认值为68。

-XX:ConcGCThreads=4:CMS垃圾回收器并行线程线,推荐值为CPU核心数。

-XX:ParallelGCThreads=8:新生代并行收集器的线程数。

-XX:MaxTenuringThreshold=10:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

-XX:CMSFullGCsBeforeCompaction=4:指定进行多少次fullGC之后,进行tenured区 内存空间压缩。

-XX:CMSMaxAbortablePrecleanTime=500:当abortable-preclean预清理阶段执行达到这个时间时就会结束。

3. 触发Full GC的场景及应对策略

年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC,对老年代GC称为MajorGC,而Full GC是对整个堆来说的,在最近几个版本的JDK里默认包括了对永生带即方法区的回收(JDK8中无永生带了),出现Full GC的时候经常伴随至少一次的Minor GC,但非绝对的。MajorGC的速度一般会比Minor GC慢10倍以上。

触发Full GC的场景及应对策略:

  • 1.System.gc()方法的调用,应对策略:通过-XX:+DisableExplicitGC来禁止调用System.gc ;
  • 2.老年代代空间不足,应对策略:让对象在Minor GC阶段被回收,让对象在新生代多存活一段时间,不要创建过大的对象及数组;
  • 3.永生区空间不足,应对策略:增大PermGen空间
  • 4.GC时出现promotionfailed和concurrent mode failure,应对策略:增大survivor space
  • 5.Minor GC后晋升到旧生代的对象大小大于老年代的剩余空间,应对策略:增大Tenured space 或下调CMSInitiatingOccupancyFraction=60
    1. 内存持续增涨达到上限导致Full GC ,应对策略:通过dumpheap 分析是否存在内存泄漏

4. Gc日志分析工具

借助GCViewer日志分析工具,可以非常直观地分析出待调优点。

可从以下几方面来分析:

  • 1.Memory,分析Totalheap、Tenuredheap、Youngheap内存占用率及其他指标,理论上内存占用率越小越好;

    2.Pause ,分析Gc pause、Fullgc pause、Total pause三个大项中各指标,理论上GC次数越少越好,GC时长越小越好;

5. MAT 堆内存分析工具

EclipseMemory Analysis Tools (MAT) 是一个分析Java堆数据的专业工具,用它可以定位内存泄漏的原因。

JVM内存设置多大合适?Xmx和Xmn如何设置?

问题:
新上线一个java服务,或者是RPC或者是WEB站点, 内存的设置该怎么设置呢?设置成多大比较合适,既不浪费内存,又不影响性能呢?

分析:
依据的原则是根据Java Performance里面的推荐公式来进行设置。

JAVA高级面试题——2019_第17张图片

具体来讲:
Java整个堆大小设置,Xmx 和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍
永久代 PermSize和MaxPermSize设置为老年代存活对象的1.2-1.5倍。
年轻代Xmn的设置为老年代存活对象的1-1.5倍。
老年代的内存大小设置为老年代存活对象的2-3倍。

BTW:
1、Sun官方建议年轻代的大小为整个堆的3/8左右, 所以按照上述设置的方式,基本符合Sun的建议。
2、堆大小=年轻代大小+年老代大小, 即xmx=xmn+老年代大小 。 Permsize不影响堆大小。
3、为什么要按照上面的来进行设置呢? 没有具体的说明,但应该是根据多种调优之后得出的一个结论。

如何确认老年代存活对象大小?
方式1(推荐/比较稳妥):
JVM参数中添加GC日志,GC日志中会记录每次FullGC之后各代的内存大小,观察老年代GC之后的空间大小。可观察一段时间内(比如2天)的FullGC之后的内存情况,根据多次的FullGC之后的老年代的空间大小数据来预估FullGC之后老年代的存活对象大小(可根据多次FullGC之后的内存大小取平均值)

方式2:(强制触发FullGC, 会影响线上服务,慎用)
方式1的方式比较可行,但需要更改JVM参数,并分析日志。同时,在使用CMS回收器的时候,有可能不能触发FullGC(只发生CMS GC),所以日志中并没有记录FullGC的日志。在分析的时候就比较难处理。
BTW:使用jstat -gcutil工具来看FullGC的时候, CMS GC是会造成2次的FullGC次数增加。 具体可参见之前写的一篇关于jstat使用的文章
所以,有时候需要强制触发一次FullGC,来观察FullGC之后的老年代存活对象大小。
注:强制触发FullGC,会造成线上服务停顿(STW),要谨慎,建议的操作方式为,在强制FullGC前先把服务节点摘除,FullGC之后再将服务挂回可用节点,对外提供服务
在不同时间段触发FullGC,根据多次FullGC之后的老年代内存情况来预估FullGC之后的老年代存活对象大小

如何触发FullGC ?
使用jmap工具可触发FullGC
jmap -dump:live,format=b,file=heap.bin 将当前的存活对象dump到文件,此时会触发FullGC
jmap -histo:live 打印每个class的实例数目,内存占用,类全名信息.live子参数加上后,只统计活的对象数量. 此时会触发FullGC

具体操作实例:
以我司的一个RPC服务为例。
BTW:刚上线的新服务,不知道该设置多大的内存的时候,可以先多设置一点内存,然后根据GC之后的情况来进行分析。
初始JVM内存参数设置为: Xmx=2G Xms=2G xmn=1G

使用jstat 查看当前的GC情况。如下图:

JAVA高级面试题——2019_第18张图片

YGC平均耗时: 173.825s/15799=11ms
FGC平均耗时:0.817s/41=19.9ms
平均大约10-20s会产生一次YGC

看起来似乎不错,YGC触发的频率不高,FGC的耗时也不高,但这样的内存设置是不是有些浪费呢?
为了快速看数据,我们使用了方式2,产生了几次FullGC,FullGC之后,使用的jmap -heap 来看的当前的堆内存情况(也可以根据GC日志来看)
heap情况如下图:(命令 : jmap -heap )

JAVA高级面试题——2019_第19张图片

上图中的concurrent mark-sweep generation即为老年代的内存描述。
老年代的内存占用为100M左右。 按照整个堆大小是老年代(FullGC)之后的3-4倍计算的话,设置各代的内存情况如下:
Xmx=512m Xms=512m Xmn=128m PermSize=128m 老年代的大小为 (512-128=384m)为老年代存活对象大小的3倍左右
调整之后的,heap情况

JAVA高级面试题——2019_第20张图片

GC情况如下:

JAVA高级面试题——2019_第21张图片

YGC 差不多在10s左右触发一次。每次YGC平均耗时大约9.41ms。可接受。
FGC平均耗时:0.016s/2=8ms
整体的GC耗时减少。但GC频率比之前的2G时的要多了一些。

注: 看上述GC的时候,发现YGC的次数突然会增多很多个,比如 从1359次到了1364次。具体原因是?

总结:
在内存相对紧张的情况下,可以按照上述的方式来进行内存的调优, 找到一个在GC频率和GC耗时上都可接受的一个内存设置,可以用较小的内存满足当前的服务需要
但当内存相对宽裕的时候,可以相对给服务多增加一点内存,可以减少GC的频率,GC的耗时相应会增加一些。 一般要求低延时的可以考虑多设置一点内存, 对延时要求不高的,可以按照上述方式设置较小内存。

补充:
永久代(方法区)并不在堆内,所以之前有看过一篇文章中描述的 整个堆大小=年轻代+年老代+永久代的描述是不正确的。

问题:
1、GC频率和GC时间多少合适?
2、YGC何时触发,FGC何时触发?
3、内存设置越大,GC的耗时是否就会越长?为什么?

JVM总结(三)Minor GC、Major GC和Full GC

一、Minor GC
Minor GC是指从年轻代空间(包括 Eden 和 Survivor 区域)回收内存。当 JVM 无法为一个新的对象分配空间时会触发
Minor GC,比如当 Eden 区满了。
Eden区满了触发MinorGC,这时会把Eden区存活的对象复制到Survivor区,当对象在Survivor区熬过一定次数的Minor
GC之后,就会晋升到老年代(当然并不是所有的对象都是这样晋升的到老年代的),当老年代满了,就会报OutofMemory异常。
所有的MinorGC都会触发全世界的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。 执行 Minor GC 操作时,不会影响到永久代。
二、Major GC vs Full GC
在目前的项目中还没有明确的定义,这点需要注意。JVM规范和垃圾收集研究论文都没有提及,但是乍一看,这些建立在我们掌握了Minor GC清理新生代上的定义并非难事:

Major GC清理Tenured区(老年代)。
Full GC清理整个heap区,包括Yong区和Tenured区。
Full GC触发条件
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法去空间不足
(4)通过Minor GC后进入老年代的平均大小 > 老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。即老年代无法存放下新年代过度到老年代的对象的时候,会触发Full GC。

补充
以上的GC总结,只是在非并发GC的触发条件下的大致原理。真正的GC情况跟实际GC器的回收机制有关。不同的GC器对Major GC 和 Full GC 的机制还是有区别的。如JVM中Serial GC, Parallel GC, CMS, G1 GC。会在后续的总结中去总结。

集合

实现 Java list,要求实现 list 的 get(), add(), remove() 三个功能函数,不能直接使用 ArrayList、LinkedList 等 Java 自带高级类(阿里面试题)

数据结构与算法精选面试题

https://blog.csdn.net/yunqiinsight/article/details/82968675

自己实现一个arraylsit

import java.util.Arrays;
import java.util.Iterator;

public class MyList<T> implements Iterable<T>{
 /*
  * 设计一种容器:可以不初始化长度,长度可以自动拓展
  */
 private int length = 10;//定义初始化容量大小的变量
 private Object[] arr = new Object[length];//建立数组用于存储元素
 private int index = 0;//建立整数索引,用于记录当前有几个元素
 public MyList(int length){
  this.length = length;
 }
 public MyList(){}
 //添加元素的方法
 public void add(T obj){
  //如果元素超出了当前数组的长度,那么需要扩展长度
  if(index>=arr.length){
   //将原本的元素拷贝到新数组,并且数组长度增加10
   Object[] newArr = Arrays.copyOf(arr,arr.length+10);
   arr = newArr;
  }
  arr[index++] = obj;//往数组内添加元素,之后索引自增1
 }
 //获取元素
 public T get(int index){
  checkIndex(index);
  return (T)arr[index];
 }
 //定义方法,验证下标
 private void checkIndex(int index){
  if(index>=this.index){
   throw new IndexOutOfBoundsException("下标越界:"+index);
  }
 }
 //删除方法
 public void remove(int index){
  checkIndex(index);
  Object[] newArr = new Object[arr.length];
  //现将需要删除的元素的前面的所有元素复制
  System.arraycopy(arr,0,newArr,0,index);
  //复制要删除元素的后面的所有的元素
  System.arraycopy(arr,index+1,newArr,index,this.index-index-1);
  this.index--;
  arr = newArr;
 }
 //set方法
 public void set(int index,T obj){
  checkIndex(index);
  arr[index] = obj;
 }
 //toString
 public String toString(){
  StringBuilder sb = new StringBuilder();
  sb.append("[");
  for (int i = 0; i < index; i++)
  {
   sb.append(arr[i]);
   if(i < index-1){
    sb.append(",");
   }
  }
  sb.append("]");
  return sb.toString();
 }
 //length
 public int length(){
  return index;
 }
 //addAll
 public void addAll(MyList<T> mm){
  for (int i = 0; i < mm.length(); i++)
  {
   this.add(mm.get(i));
  }
 }
 //toArray
 public Object[] toArray(){
  Object[] res = Arrays.copyOf(arr, index);
  return res;
 }
 //remove
 public void remove(T t){
  for (int i = 0; i < index; i++)
  {
   if(arr[i].equals(t)){
    this.remove(i);
   }
  }
 }
 //removeAll
 public void removeAll(MyList<T> mm){
  for (int i = 0; i < mm.length(); i++)
  {
   this.remove(mm.get(i));
  }
 }
 //insert
 public void insert(int index,T t){
  checkIndex(index);
  //建立新数组
  Object[] newArr = new Object[arr.length+1];
  //复制将要添加的元素索引的之前的所有元素
  System.arraycopy(arr, 0, newArr, 0, index);
  newArr[index] = t;//将要插入的元素放入新数组的指定位置
  System.arraycopy(arr, index, newArr,index+1,arr.length-index);
  this.index++;
  arr = newArr;
 }
 @Override
 public Iterator<T> iterator()
 {
  
  Iterator<T> ite = new Iterator<T>()
  {
   int index = 0;
   @Override
   public boolean hasNext()
   {
    if(index<MyList.this.index){
     return true;
    }else{
     return false;
    }
   }
   @Override
   public T next()
   {
    return (T)arr[index++];
   }};
  return ite;
 }
————————————————
版权声明:本文为CSDN博主「静夜思乡」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/wanghuiwei888/article/details/78876134

Hash是什么?HashCode是什么?Hash表是什么?为什么要用HashCode?

Hash
百度百科解释:
Hash 又叫 散列函数,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。


常用HASH函数
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快地定位。常用Hash函数有:
1.直接寻址法。取关键字或关键字的某个线性函数值为散列地址。即H(key)=key或H(key) = a·key + b,其中a和b为常数(这种散列函数叫做自身函数)
2. 数字分析法。分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
3. 平方取中法。取关键字平方后的中间几位作为散列地址。
4. 折叠法。将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。
5. 随机数法。选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合。
6. 除留余数法。取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生碰撞。

处理冲突方法
1.开放寻址法;Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),其中H(key)为散列函数,m为散列表长,di为增量序列,可有下列三种取法:
1). di=1,2,3,…,m-1,称线性探测再散列;
2). di=12,-12,22,-22,32,…,±k2,(k<=m/2)称二次探测再散列;
3). di=伪随机数序列,称伪随机探测再散列。
2. 再散列法:Hi=RHi(key),i=1,2,…,k RHi均是不同的散列函数,即在同义词产生地址冲突时计算另一个散列函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但增加了计算时间。
3. 链地址法(拉链法)
4. 建立一个公共溢出区

查找性能分析
散列表的查找过程基本上和造表过程相同。一些关键码可通过散列函数转换的地址直接找到,另一些关键码在散列函数得到的地址上产生了冲突,需要按处理冲突的方法进行查找。在介绍的三种处理冲突的方法中,产生冲突后的查找仍然是给定值与关键码进行比较的过程。所以,对散列表查找效率的量度,依然用平均查找长度来衡量。
查找过程中,关键码的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。影响产生冲突多少有以下三个因素:
1.散列函数是否均匀;
2. 处理冲突的方法;
3.散列表的装填因子。
散列表的装填因子定义为:α= 填入表中的元素个数/散列表的长度
α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,填入表中的元素较多,产生冲突的可能性就越大;α越小,填入表中的元素较少,产生冲突的可能性就越小。
实际上,散列表的平均查找长度是装填因子α的函数,只是不同处理冲突的方法有不同的函数。
了解了hash基本定义,就不能不提到一些著名的hash算法,MD5和SHA-1可以说是应用最广泛的Hash算法,而它们都是以MD4为基础设计的。

常用hash算法的介绍:
(1)MD4
MD4(RFC 1320)是 MIT 的Ronald L. Rivest在 1990 年设计的,MD 是 Message Digest(消息摘要) 的缩写。它适用在32位字长的处理器上用高速软件实现——它是基于 32位操作数的位操作来实现的。
(2)MD5
MD5(RFC 1321)是 Rivest 于1991年对MD4的改进版本。它对输入仍以512位分组,其输出是4个32位字的级联,与 MD4 相同。MD5比MD4来得复杂,并且速度较之要慢一点,但更安全,在抗分析和抗差分方面表现更好。
(3)SHA-1及其他
SHA1是由NIST NSA设计为同DSA一起使用的,它对长度小于2^64的输入,产生长度为160bit的散列值,因此抗穷举(brute-force)性更好。SHA-1 设计时基于和MD4相同原理,并且模仿了该算法。 [2]


HashCode
百度百科的解释:

hashCode是jdk根据对象的地址或者字符串或者数字,通过hash算法,算出来的int类型的数值 详细了解请 参考 public int hashCode()返回该对象的哈希码值。支持此方法是为了提高哈希表(例如 java.util.Hashtable 提供的哈希表)的性能。

JAVA高级面试题——2019_第22张图片
hash表
通过hash算法得到的hash值就在这张hash表中,也就是说,hash表就是所有的hash值组成的,有很多种hash函数,也就代表着有很多种算法得到hash值

为什么要用HashCode?

在Java集合中有两类,一类是List,一类是Set

他们之间的区别就在于List集合中的元素师有序的,且可以重复,而Set集合中元素是无序不可重复的。

对于List好处理,但是对于Set而言我们要如何来保证元素不重复呢?

通过迭代来equals()是否相等。数据量小还可以接受,当我们的数据量大的时候效率可想而知(当然我们可以利用算法进行优化)。

比如我们向HashSet插入1000数据,难道我们真的要迭代1000次,调用1000次equals()方法吗?hashCode提供了解决方案。

怎么实现?我们先看hashCode的源码(Object)。

public native int hashCode();  

它是一个本地方法,它的实现与本地机器有关,这里我们暂且认为他返回的是对象存储的物理位置(实际上不是,这里写是便于理解)。

当我们向一个集合中添加某个元素,集合会首先调用hashCode方法,这样就可以直接定位它所存储的位置,

若该处没有其他元素,则直接保存。

若该处已经有元素存在,就调用equals方法来匹配这两个元素是否相同,

相同则不存,不同则散列到其他位置。

这样处理,当我们存入大量元素时就可以大大减少调用equals()方法的次数,极大地提高了效率。

所以hashCode在上面扮演的角色为寻域(寻找某个对象在集合中区域位置)。

hashCode可以将集合分成若干个区域,每个对象都可以计算出他们的hash码,可以将hash码分组,每个分组对应着某个存储区域,根据一个对象的hash码就可以确定该对象所存储区域,这样就大大减少查询匹配元素的数量,提高了查询效率。

hashcode详解

一、hashcode是什么?

1、hash和hash表是什么?

想要知道这个hashcode,首先得知道hash,通过百度百科看一下
JAVA高级面试题——2019_第23张图片
          JAVA高级面试题——2019_第24张图片

hash是一个函数,该函数中的实现就是一种算法,就是通过一系列的算法来得到一个hash值,这个时候,我们就需要知道另一个东西,hash表,通过hash算法得到的hash值就在这张hash表中,也就是说,hash表就是所有的hash值组成的,有很多种hash函数,也就代表着有很多种算法得到hash值,如上面截图的三种,等会我们就拿第一种来说。

2、hashcode

有了前面的基础,这里讲解就简单了,hashcode就是通过hash函数得来的,通俗的说,就是通过某一种算法得到的,hashcode就是在hash表中有对应的位置。

每个对象都有hashcode,对象的hashcode怎么得来的呢?

首先一个对象肯定有物理地址,在别的博文中会hashcode说成是代表对象的地址,这里肯定会让读者形成误区,对象的物理地址跟这个hashcode地址不一样,hashcode代表对象的地址说的是对象在hash表中的位置,物理地址说的对象存放在内存中的地址,那么对象如何得到hashcode呢?通过对象的内部地址(也就是物理地址)转换成一个整数,然后该整数通过hash函数的算法就得到了hashcode,所以,hashcode是什么呢?就是在hash表中对应的位置。这里如果还不是很清楚的话,举个例子,hash表中有 hashcode为1、hashcode为2、(…)3、4、5、6、7、8这样八个位置,有一个对象A,A的物理地址转换为一个整数17(这是假如),就通过直接取余算法,17%8=1,那么A的hashcode就为1,且A就在hash表中1的位置。肯定会有其他疑问,接着看下面,这里只是举个例子来让你们知道什么是hashcode的意义。

二、hashcode有什么作用呢?

前面说了这么多关于hash函数,和hashcode是怎么得来的,还有hashcode对应的是hash表中的位置,可能大家就有疑问,为什么hashcode不直接写物理地址呢,还要另外用一张hash表来代表对象的地址?接下来就告诉你hashcode的作用,

1、HashCode的存在主要是为了查找的快捷性,HashCode是用来在散列存储结构中确定对象的存储地址的(后半句说的用hashcode来代表对象就是在hash表中的位置)

为什么hashcode就查找的更快,比如:我们有一个能存放1000个数这样大的内存中,在其中要存放1000个不一样的数字,用最笨的方法,就是存一个数字,就遍历一遍,看有没有相同得数,当存了900个数字,开始存901个数字的时候,就需要跟900个数字进行对比,这样就很麻烦,很是消耗时间,用hashcode来记录对象的位置,来看一下。hash表中有1、2、3、4、5、6、7、8个位置,存第一个数,hashcode为1,该数就放在hash表中1的位置,存到100个数字,hash表中8个位置会有很多数字了,1中可能有20个数字,存101个数字时,他先查hashcode值对应的位置,假设为1,那么就有20个数字和他的hashcode相同,他只需要跟这20个数字相比较(equals),如果每一个相同,那么就放在1这个位置,这样比较的次数就少了很多,实际上hash表中有很多位置,这里只是举例只有8个,所以比较的次数会让你觉得也挺多的,实际上,如果hash表很大,那么比较的次数就很少很少了。 通过对原始方法和使用hashcode方法进行对比,我们就知道了hashcode的作用,并且为什么要使用hashcode了

三、equals方法和hashcode的关系?

通过前面这个例子,大概可以知道,先通过hashcode来比较,如果hashcode相等,那么就用equals方法来比较两个对象是否相等,用个例子说明:上面说的hash表中的8个位置,就好比8个桶,每个桶里能装很多的对象,对象A通过hash函数算法得到将它放到1号桶中,当然肯定有别的对象也会放到1号桶中,如果对象B也通过算法分到了1号桶,那么它如何识别桶中其他对象是否和它一样呢,这时候就需要equals方法来进行筛选了。

1、如果两个对象equals相等,那么这两个对象的HashCode一定也相同

2、如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置

这两条你们就能够理解了。

四、为什么equals方法重写的话,建议也一起重写hashcode方法?

(如果对象的equals方法被重写,那么对象的HashCode方法也尽量重写)

举个例子,其实就明白了这个道理,

比如:有个A类重写了equals方法,但是没有重写hashCode方法,看输出结果,对象a1和对象a2使用equals方法相等,按照上面的hashcode的用法,那么他们两个的hashcode肯定相等,但是这里由于没重写hashcode方法,他们两个hashcode并不一样,所以,我们在重写了equals方法后,尽量也重写了hashcode方法,通过一定的算法,使他们在equals相等时,也会有相同的hashcode值。
JAVA高级面试题——2019_第25张图片

实例:现在来看一下String的源码中的equals方法和hashcode方法。这个类就重写了这两个方法,现在为什么需要重写这两个方法了吧?

equals方法:其实跟我上面写的那个例子是一样的原理,所以通过源码又知道了String的equals方法验证的是两个字符串的值是否一样。还有Double类也重写了这些方法。很多类有比较这类的,都重写了这两个方法,因为在所有类的父类Object中。equals的功能就是 “”号的功能。你们还可以比较String对象的equals和的区别啦。这里不再说明。

JAVA高级面试题——2019_第26张图片

hashcode方法
JAVA高级面试题——2019_第27张图片

HashMap是如何存储的?

HashMap的工作原理是近年来常见的Java面试题。几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道HashTable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深。这题经常出现在高级或中高级面试中。投资银行更喜欢问这个问题,甚至会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂。让我们开始探索的旅程吧!
JAVA高级面试题——2019_第28张图片

先来些简单的问题
  “你用过HashMap吗?” “什么是HashMap?你为什么用到它?”

几乎每个人都会回答“是的”,然后回答HashMap的一些特性,譬如HashMap可以接受null键值和值,而HashTable则不能;HashMap是非synchronized;HashMap很快;以及HashMap储存的是键值对等等。这显示出你已经用过HashMap,而且对它相当的熟悉。但是面试官来个急转直下,从此刻开始问出一些刁钻的问题,关于HashMap的更多基础的细节。面试官可能会问出下面的问题:

“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”

你也许会回答“我没有详查标准的Java API,你可以看看Java源代码或者Open JDK。”“我可以用Google找到答案。”

但一些面试者可能可以给出答案,“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。但是这仅仅是故事的开始,当面试官加入一些Java程序员每天要碰到的实际场景的时候,错误的答案频现。下个问题可能是关于HashMap中的碰撞探测(collision detection)以及碰撞的解决方法:

“当两个对象的hashcode相同会发生什么?” 从这里开始,真正的困惑开始了,一些面试者会回答因为hashcode相同,所以两个对象是相等的,HashMap将会抛出异常,或者不会存储它们。然后面试官可能会提醒他们有equals()和hashCode()两个方法,并告诉他们两个对象就算hashcode相同,但是它们可能并不相等。一些面试者可能就此放弃,而另外一些还能继续挺进,他们回答“因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用LinkedList存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在LinkedList中。”这个答案非常的合理,虽然有很多种处理碰撞的方法,这种方法是最简单的,也正是HashMap的处理方法。但故事还没有完结,面试官会继续问:

“如果两个键的hashcode相同,你如何获取值对象?” 面试者会回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给出答案:将会遍历LinkedList直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到HashMap在LinkedList中存储的是键值对,否则他们不可能回答出这一题。

其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到LinkedList中正确的节点,最终找到要找的值对象。完美的答案!

许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

如果你认为到这里已经完结了,那么听到下面这个问题的时候,你会大吃一惊。“如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?”除非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

如果你能够回答这道问题,下面的问题来了:“你了解重新调整HashMap大小存在什么问题吗?”你可能回答不上来,这时面试官会提醒你当多线程的情况下,可能产生条件竞争(race condition)。

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?:)

热心的读者贡献了更多的关于HashMap的问题:

为什么String, Interger这样的wrapper类适合作为键? String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
我们可以使用CocurrentHashMap来代替HashTable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道HashTable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。看看这篇博客查看Hashtable和ConcurrentHashMap的区别。
  我个人很喜欢这个问题,因为这个问题的深度和广度,也不直接的涉及到不同的概念。让我们再来看看这些问题设计哪些知识点:

hashing的概念
HashMap中解决碰撞的方法
equals()和hashCode()的应用,以及它们在HashMap中的重要性
不可变对象的好处
HashMap多线程的条件竞争
重新调整HashMap的大小
  总结
  HashMap的工作原理
HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用LinkedList来解决碰撞问题,当发生碰撞了,对象将会储存在LinkedList的下一个节点中。 HashMap在每个LinkedList节点中储存键值对对象。

当两个不同的键对象的hashcode相同时会发生什么? 它们会储存在同一个bucket位置的LinkedList中。键对象的equals()方法用来找到键值对。

因为HashMap的好处非常多,我曾经在电子商务的应用中使用HashMap作为缓存。因为金融领域非常多的运用Java,也出于性能的考虑,我们会经常用到HashMap和ConcurrentHashMap。你可以查看更多的关于HashMap和HashTable的文章。

HashMap碰撞原理,jdk是如何解决碰撞问题的?

hashmap冲突的解决方法以及原理分析:
在Java编程语言中,最基本的结构就是两种,一种是数组,一种是模拟指针(引用),所有的数据结构都可以用这两个基本结构构造,HashMap也一样。当程序试图将多个 key-value 放入 HashMap 中时,以如下代码片段为例:

HashMap<String,Object> m=new HashMap<String,Object>(); 
m.put("a", "rrr1"); 
m.put("b", "tt9"); 
m.put("c", "tt8"); 
m.put("d", "g7"); 
m.put("e", "d6"); 
m.put("f", "d4"); 
m.put("g", "d4"); 
m.put("h", "d3"); 
m.put("i", "d2"); 
m.put("j", "d1"); 
m.put("k", "1"); 
m.put("o", "2"); 
m.put("p", "3"); 
m.put("q", "4"); 
m.put("r", "5"); 
m.put("s", "6"); 
m.put("t", "7"); 
m.put("u", "8"); 
m.put("v", "9");

HashMap 采用一种所谓的“Hash 算法”来决定每个元素的存储位置。当程序执行 map.put(String,Obect)方法 时,系统将调用String的 hashCode() 方法得到其 hashCode 值——每个 Java 对象都有 hashCode() 方法,都可通过该方法获得它的 hashCode 值。得到这个对象的 hashCode 值之后,系统会根据该 hashCode 值来决定该元素的存储位置。源码如下:

Java代码 收藏代码

   public V put(K key, V value) {  
        if (key == null)  
            return putForNullKey(value);  
        int hash = hash(key.hashCode());  
        int i = indexFor(hash, table.length);  
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
            Object k;  
            //判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。  
            //如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果不相同,这时就是产生了hash冲突。  
            //Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。  
            //系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),  
            //那系统必须循环到最后才能找到该元素。  
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
                V oldValue = e.value;  
                e.value = value;  
                return oldValue;  
            }  
        }  
        modCount++;  
        addEntry(hash, key, value, i);  
        return null;  
    }  

上面程序中用到了一个重要的内部接口:Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可.HashMap程序经过我改造,我故意的构造出了hash冲突现象,因为HashMap的初始大小16,但是我在hashmap里面放了超过16个元素,并且我屏蔽了它的resize()方法。不让它去扩容。这时HashMap的底层数组Entry[] table结构如下:

JAVA高级面试题——2019_第29张图片

Hashmap里面的bucket出现了单链表的形式,散列表要解决的一个问题就是散列值的冲突问题,通常是两种方法:链表法和开放地址法。链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。java.util.HashMap采用的链表法的方式,链表是单向链表。形成单链表的核心代码如下:

Java代码 收藏代码

void addEntry(int hash, K key, V value, int bucketIndex) {  
    Entry<K,V> e = table[bucketIndex];  
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
    if (size++ >= threshold)  
        resize(2 * table.length);  
bsp;  
 上面方法的代码很简单,但其中包含了一个设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。
HashMap里面没有出现hash冲突时,没有形成单链表时,hashmap查找元素很快,get()方法能够直接定位到元素,但是出现单链表后,单个bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。 当创建 HashMap 时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。

一、HashMap概述

HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。

Map map = Collections.synchronizedMap(new HashMap());

二、HashMap的数据结构

HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。

JAVA高级面试题——2019_第30张图片

图中,紫色部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。

我们看看HashMap中Entry类的代码:

/** Entry是单向链表。    
 * 它是 “HashMap链式存储法”对应的链表。    
 *它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数  
**/  
static class Entry<K,V> implements Map.Entry<K,V> {    
    final K key;    
    V value;    
    // 指向下一个节点    
    Entry<K,V> next;    
    final int hash;    
   
    // 构造函数。    
    // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"    
    Entry(int h, K k, V v, Entry<K,V> n) {    
        value = v;    
        next = n;    
        key = k;    
        hash = h;    
    }    
   
    public final K getKey() {    
        return key;    
    }    
   
    public final V getValue() {    
        return value;    
    }    
   
    public final V setValue(V newValue) {    
        V oldValue = value;    
        value = newValue;    
        return oldValue;    
    }    
   
    // 判断两个Entry是否相等    
    // 若两个Entry的“key”和“value”都相等,则返回true。    
    // 否则,返回false    
    public final boolean equals(Object o) {    
        if (!(o instanceof Map.Entry))    
            return false;    
        Map.Entry e = (Map.Entry)o;    
        Object k1 = getKey();    
        Object k2 = e.getKey();    
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {    
            Object v1 = getValue();    
            Object v2 = e.getValue();    
            if (v1 == v2 || (v1 != null && v1.equals(v2)))    
                return true;    
        }    
        return false;    
    }    
   
    // 实现hashCode()    
    public final int hashCode() {    
        return (key==null   ? 0 : key.hashCode()) ^    
               (value==null ? 0 : value.hashCode());    
    }    
   
    public final String toString() {    
        return getKey() + "=" + getValue();    
    }    
   
    // 当向HashMap中添加元素时,绘调用recordAccess()。    
    // 这里不做任何处理    
    void recordAccess(HashMap<K,V> m) {    
    }    
   
    // 当从HashMap中删除元素时,绘调用recordRemoval()。    
    // 这里不做任何处理    
    void recordRemoval(HashMap<K,V> m) {    
    }    
}

HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。

三、HashMap源码分析

   1、关键属性

先看看HashMap类中的一些关键属性:

1 transient Entry[] table;//存储元素的实体数组
2  
3 transient int size;//存放元素的个数
4  
5 int threshold; //临界值   当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
6 
7  final float loadFactor; //加载因子
8  
9 transient int modCount;//被修改的次数

其中loadFactor加载因子是表示Hsah表中元素的填满的程度.

若:加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.链表长度会越来越长,查找效率降低。

反之,加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.表中的数据将过于稀疏(很多空间还没用,就开始扩容了)

冲突的机会越大,则查找的成本越高.

因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.

如果机器内存足够,并且想要提高查询速度的话可以将加载因子设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将加载因子设置大一点。不过一般我们都不用去设置它,让它取默认值0.75就好了。

2、构造方法

下面看看HashMap的几个构造方法:

public HashMap(int initialCapacity, float loadFactor) {
 2         //确保数字合法
 3         if (initialCapacity < 0)
 4             throw new IllegalArgumentException("Illegal initial capacity: " +
 5                                               initialCapacity);
 6         if (initialCapacity > MAXIMUM_CAPACITY)
 7             initialCapacity = MAXIMUM_CAPACITY;
 8         if (loadFactor <= 0 || Float.isNaN(loadFactor))
 9             throw new IllegalArgumentException("Illegal load factor: " +
10                                               loadFactor);
11 
12         // Find a power of 2 >= initialCapacity
13         int capacity = 1;   //初始容量
14         while (capacity < initialCapacity)   //确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂
15             capacity <<= 1;
16 
17         this.loadFactor = loadFactor;
18         threshold = (int)(capacity * loadFactor);
19         table = new Entry[capacity];
20        init();
21    }
22 
23     public HashMap(int initialCapacity) {
24         this(initialCapacity, DEFAULT_LOAD_FACTOR);
25    }
26 
27     public HashMap() {
28         this.loadFactor = DEFAULT_LOAD_FACTOR;
29         threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
30         table = new Entry[DEFAULT_INITIAL_CAPACITY];
31        init();
32     }

我们可以看到在构造HashMap的时候如果我们指定了加载因子和初始容量的话就调用第一个构造方法,否则的话就是用默认的。默认初始容量为16,默认加载因子为0.75。我们可以看到上面代码中13-15行,这段代码的作用是确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂,至于为什么要把容量设置为2的n次幂,我们等下再看。

重点分析下HashMap中用的最多的两个方法put和get

   3、存储数据

下面看看HashMap存储数据的过程是怎样的,首先看看HashMap的put方法:

public V put(K key, V value) {
     // 若“key为null”,则将该键值对添加到table[0]中。
         if (key == null) 
            return putForNullKey(value);
     // 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。
         int hash = hash(key.hashCode());
     //搜索指定hash值在对应table中的索引
         int i = indexFor(hash, table.length);
     // 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!
         for (Entry<K,V> e = table[i]; e != null; e = e.next) { 
             Object k;
              if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //如果key相同则覆盖并返回旧值
                  V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
              }
         }
     //修改次数+1
         modCount++;
     //将key-value添加到table[i]处
     addEntry(hash, key, value, i);
     return null;
}

上面程序中用到了一个重要的内部接口:Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。

我们慢慢的来分析这个函数,第2和3行的作用就是处理key值为null的情况,我们看看putForNullKey(value)方法:

1 private V putForNullKey(V value) {
 2         for (Entry<K,V> e = table[0]; e != null; e = e.next) {
 3             if (e.key == null) {   //如果有key为null的对象存在,则覆盖掉
 4                 V oldValue = e.value;
 5                 e.value = value;
 6                 e.recordAccess(this);
 7                 return oldValue;
 8            }
 9        }
10         modCount++;
11         addEntry(0, null, value, 0); //如果键为null的话,则hash值为0
12         return null;
13     }

注意:如果key为null的话,hash值为0,对象存储在数组中索引为0的位置。即table[0]

我们再回去看看put方法中第4行,它是通过key的hashCode值计算hash码,下面是计算hash码的函数:

1  //计算hash值的方法 通过键的hashCode来计算
2     static int hash(int h) {
3         // This function ensures that hashCodes that differ only by
4         // constant multiples at each bit position have a bounded
5         // number of collisions (approximately 8 at default load factor).
6         h ^= (h >>> 20) ^ (h >>> 12);
7         return h ^ (h >>> 7) ^ (h >>> 4);
8     }

得到hash码之后就会通过hash码去计算出应该存储在数组中的索引,计算索引的函数如下:

1     static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
2         return h & (length-1);  //这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
3     }

这个我们要重点说下,我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多,这也是HashMap对Hashtable的一个改进。

接下来,我们分析下为什么哈希表的容量一定要是2的整数次幂。首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

这看上去很简单,其实比较有玄机的,我们举个例子来说明:

假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下:

   h & (table.length-1)                     hash                             table.length-1
   8 & (15-1):                                 0100                   &              1110                   =                0100
   9 & (15-1):                                 0101                   &              1110                   =                0100
   -----------------------------------------------------------------------------------------------------------------------
   8 & (16-1):                                 0100                   &              1111                   =                0100
   9 & (16-1):                                 0101                   &              1111                   =                0101

从上面的例子中可以看出:当它们和15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hash值会与15-1(1110)进行“与”,那么 最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!而当数组长度为16时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。

所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

根据上面 put 方法的源代码可以看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。

1 void addEntry(int hash, K key, V value, int bucketIndex) {
2         Entry e = table[bucketIndex]; //如果要加入的位置有值,将该位置原先的值设置为新entry的next,也就是新entry链表的下一个节点
3         table[bucketIndex] = new Entry<>(hash, key, value, e);
4         if (size++ >= threshold) //如果大于临界值就扩容
5             resize(2 * table.length); //以2的倍数扩容
6     }
 

参数bucketIndex就是indexFor函数计算出来的索引值,第2行代码是取得数组中索引为bucketIndex的Entry对象,第3行就是用hash、key、value构建一个新的Entry对象放到索引为bucketIndex的位置,并且将该位置原先的对象设置为新对象的next构成链表。

第4行和第5行就是判断put后size是否达到了临界值threshold,如果达到了临界值就要进行扩容,HashMap扩容是扩为原来的两倍。

4、调整大小

resize()方法如下:

重新调整HashMap的大小,newCapacity是调整后的单位

 1     void resize(int newCapacity) {
 2         Entry[] oldTable = table;
 3         int oldCapacity = oldTable.length;
 4         if (oldCapacity == MAXIMUM_CAPACITY) {
 5             threshold = Integer.MAX_VALUE;
 6             return;
 7        }
 8 
 9         Entry[] newTable = new Entry[newCapacity];
10         transfer(newTable);//用来将原先table的元素全部移到newTable里面
11         table = newTable;  //再将newTable赋值给table
12         threshold = (int)(newCapacity * loadFactor);//重新计算临界值
13     }

新建了一个HashMap的底层数组,上面代码中第10行为调用transfer方法,将HashMap的全部元素添加到新的HashMap中,并重新计算元素在新的数组中的索引位置

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,扩容是需要进行数组复制的,复制数组是非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

5、数据读取

1.public V get(Object key) {   
2.    if (key == null)   
3.        return getForNullKey();   
4.    int hash = hash(key.hashCode());   
5.    for (Entry e = table[indexFor(hash, table.length)];   
6.        e != null;   
7.        e = e.next) {   
8.        Object k;   
9.        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))   
10.            return e.value;   
11.    }   
12.    return null;   
13.}  

有了上面存储时的hash算法作为基础,理解起来这段代码就很容易了。从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

6、HashMap的性能参数:

HashMap 包含如下几个构造器:

HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。

initialCapacity:HashMap的最大容量,即为底层数组的长度。

loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。

负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

HashMap的实现中,通过threshold字段来判断HashMap的最大容量:

threshold = (int)(capacity * loadFactor);  

结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍

Java 8中HashMap冲突解决
在Java 8 之前,HashMap和其他基于map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)。为了解决在频繁冲突时hashmap性能降低的问题,Java 8中使用平衡树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)。
在Java 8中使用常量TREEIFY_THRESHOLD来控制是否切换到平衡树来存储。目前,这个常量值是8,这意味着当有超过8个元素的索引一样时,HashMap会使用树来存储它们。
这一改变是为了继续优化常用类。大家可能还记得在Java 7中为了优化常用类对ArrayList和HashMap采用了延迟加载的机制,在有元素加入之前不会分配内存,这会减少空的链表和HashMap占用的内存。
这一动态的特性使得HashMap一开始使用链表,并在冲突的元素数量超过指定值时用平衡二叉树替换链表。不过这一特性在所有基于hash table的类中并没有,例如Hashtable和WeakHashMap。
目前,只有ConcurrentHashMap,LinkedHashMap和HashMap会在频繁冲突的情况下使用平衡树。

什么时候会产生冲突
HashMap中调用hashCode()方法来计算hashCode。
由于在Java中两个不同的对象可能有一样的hashCode,所以不同的键可能有一样hashCode,从而导致冲突的产生。

总结
HashMap在处理冲突时使用链表存储相同索引的元素。
从Java 8开始,HashMap,ConcurrentHashMap和LinkedHashMap在处理频繁冲突时将使用平衡树来代替链表,当同一hash桶中的元素数量超过特定的值便会由链表切换到平衡树,这会将get()方法的性能从O(n)提高到O(logn)。
当从链表切换到平衡树时,HashMap迭代的顺序将会改变。不过这并不会造成什么问题,因为HashMap并没有对迭代的顺序提供任何保证。
从Java 1中就存在的Hashtable类为了保证迭代顺序不变,即便在频繁冲突的情况下也不会使用平衡树。这一决定是为了不破坏某些较老的需要依赖于Hashtable迭代顺序的Java应用。
除了Hashtable之外,WeakHashMap和IdentityHashMap也不会在频繁冲突的情况下使用平衡树。
使用HashMap之所以会产生冲突是因为使用了键对象的hashCode()方法,而equals()和hashCode()方法不保证不同对象的hashCode是不同的。需要记住的是,相同对象的hashCode一定是相同的,但相同的hashCode不一定是相同的对象。
在HashTable和HashMap中,冲突的产生是由于不同对象的hashCode()方法返回了一样的值。
以上就是Java中HashMap如何处理冲突。这种方法被称为链地址法,因为使用链表存储同一桶内的元素。通常情况HashMap,HashSet,LinkedHashSet,LinkedHashMap,ConcurrentHashMap,HashTable,IdentityHashMap和WeakHashMap均采用这种方法处理冲突。

从JDK 8开始,HashMap,LinkedHashMap和ConcurrentHashMap为了提升性能,在频繁冲突的时候使用平衡树来替代链表。因为HashSet内部使用了HashMap,LinkedHashSet内部使用了LinkedHashMap,所以他们的性能也会得到提升。

HashMap的快速高效,使其使用非常广泛。其原理是,调用hashCode()和equals()方法,并对hashcode进行一定的哈希运算得到相应value的位置信息,将其分到不同的桶里。桶的数量一般会比所承载的实际键值对多。当通过key进行查找的时候,往往能够在常数时间内找到该value。

但是,当某种针对key的hashcode的哈希运算得到的位置信息重复了之后,就发生了哈希碰撞。这会对HashMap的性能产生灾难性的影响。

在Java 8 之前, 如果发生碰撞往往是将该value直接链接到该位置的其他所有value的末尾,即相互碰撞的所有value形成一个链表。

因此,在最坏情况下,HashMap的查找时间复杂度将退化到O(n).

但是在Java 8 中,该碰撞后的处理进行了改进。当一个位置所在的冲突过多时,存储的value将形成一个排序二叉树,排序依据为key的hashcode。

则,在最坏情况下,HashMap的查找时间复杂度将从O(1)退化到O(logn)。

虽然是一个小小的改进,但意义重大:

1、O(n)到O(logn)的时间开销。

2、如果恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能够发送大量请求导致哈希碰撞,然后不停访问这些key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(DoS)。

原文链接:https://blog.csdn.net/cpcpcp123/article/details/52744331

HashMap的key重复,那么value会被覆盖吗?

如果key相同,但是hashcode不同,那么value不会被覆盖

如果key相同,并且hashCode相同,那么value会被覆盖

HashMap如何实现相同key存入数据后不被覆盖?

需求:

实现一个在HashMap中存入(任意类型)相同的key值后,key中的value不会被覆盖,而是能够进行叠加!

拿到一个需求的时候,我们要先进行分析,看此需求能否实现,基于已有的知识(经验),然后在通过目前的一些技术看此需求如何实现。

要实现在HashMap中插入相同的key值,内容不被覆盖,那么肯定要了解HashMap的一些机制,首先看一下HashMap的put方法:

从JDK API中看到HashMap的put如何先前存储了一个key(键),在指定相同的key(键)的时候,会用新的值替换旧的值。

如下的代码示例:

public static void main(String[] args) {
    Map<String, Object> map = new HashMap<>();

    map.put("aflyun", "Java编程技术乐园");

    map.put("aflyun", "生活在长沙的延安人");

    System.out.println(map.toString());
}
--打印:--
{aflyun=生活在长沙的延安人}    

通过上面的示例分析:为什么存入相同的key后,旧值就被新值替换了呢?

要想知道具体原因,那只能去看HashMap的源码实现了。看一下put(K key, V value)方法了,本篇HashMap源码是JDK1.8版本!

/**

  • HashMap 的put方法
    /
    public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
    }
    /
  • HashMap 的containsKey方法
    **/
    public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
    }

/**

  • 将存入的key进行hash操作,也就是使用key.hashCode()!
    **/
    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    判断put和判断key是否是同一个key的时候,使用大概如下判断逻辑:

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
先判断Hash是否一致,然后在判断传入key和当前集合中是否有相同的key。如果key相同,则新值替换旧值。其中在判断中使用了

==
equals
==和 equals 的区别有时候面试会问到,如何你知道这两个的区别不仅看源码能够很好的理解,并且遇到面试也不怕了。

tips:简述==和 equals 的区别>

1)对于==,如果作用于基本数据类型的变量,则直接比较其存储的 “值”是否相等;如果作用于引用类型的变量,则比较的是所指向的对象的地址!(确切的说,是堆内存地址)
 
 2)对于equals方法,注意:equals方法不能作用于基本数据类型的变量。如果没有对equals方法进行重写,则比较的是引用类型的变量所指向的对象的地址;诸如String等类对equals方法进行了重写的话,比较的是所指向的对象的内容。

注:对于第二种类型,除非是同一个new出来的对象,他们的比较后的结果为true,否则比较后结果为false。因为每new一次,都会重新开辟堆内存空间。
equals()方法介绍:

JAVA当中所有的类都是继承于Object这个超类的,在Object类中定义了一个equals的方法,equals的源码是这样写的:

public boolean equals(Object obj) {
    //this - s1
    //obj - s2
    return (this == obj);
}

可以看到,这个方法的初始默认行为是比较对象的内存地址值,一般来说,意义不大。所以,在一些类库当中这个方法被重写了,如String、Integer、Date。在这些类当中equals有其自身的实现(一般都是用来比较对象的成员变量值是否相同),而不再是比较类在堆内存中的存放地址了。
所以说,对于复合数据类型之间进行equals比较,在没有覆写equals方法的情况下,他们之间的比较还是内存中的存放位置的地址值,跟双等号(==)的结果相同;如果被复写,按照复写的要求来。

我们对上面的两段内容做个总结吧:

== 的作用:
  基本类型:比较的就是值是否相同
  引用类型:比较的就是地址值是否相同
equals 的作用:
  引用类型:默认情况下,比较的是地址值。
注:不过,我们可以根据情况自己重写该方法。一般重写都是自动生成,比较对象的成员变量值是否相同

有了上面的分析基础,那针对上面String类型的key的话,那实现起来就比较简单了!因为String中已经实现了HashCode和 equals代码如下:

自定义HashMap

public class MyHashMap<K> extends HashMap<K,String> {

    /**
     * 使用HashMap中containsKey判断key是否已经存在
     * @param key
     * @param value
     * @return
     */
    @Override
    public String put(K key, String value) {
        String newV = value;
        if (containsKey(key)) {
            String oldV = get(key);
            newV = oldV + "---" + newV;
        }
        return super.put(key, newV);
    }
}

String类型key的进行put操作

public static void main(String[] args) {
    MyHashMap<String> map = new MyHashMap<String>();

    map.put("aflyun", "Java编程技术乐园");

    map.put("aflyun", "生活在长沙的延安人");

    map.put("aflyun", "期待你加入乐园");

    System.out.println(map.toString());
}

--打印:---
{aflyun=Java编程技术乐园---生活在长沙的延安人---期待你加入乐园}

此时同样的key内容是进行叠加的,不是进行替换!那如何是自定义的类,要当作key,那要怎么做呢?
其实也就是重写了hashCode和equals就可以了。

public class PrettyGirl {
    /**
     * 姑娘唯一认证ID
     */
    private String id;
    /**
     * 姑娘姓字名谁
     */
    private String name;


    @Override
    public boolean equals(Object o) {
        if (this == o) {return true;}
        if (o == null || getClass() != o.getClass()) {return false;}
        PrettyGirl that = (PrettyGirl) o;
        return Objects.equals(id, that.id) &&
                Objects.equals(name, that.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name);
    }
}

自定义类型当做key的进行put操作

public static void main(String[] args) {

    PrettyGirl prettyGirl = new PrettyGirl();

    Map<PrettyGirl,String> map = new HashMap<>();

    map.put(prettyGirl, "Java编程技术乐园");

    map.put(prettyGirl, "生活在长沙的延安人");

    map.put(prettyGirl, "期待和你加入乐园");

    System.out.println("map :" + map.toString());

    MyHashMap<PrettyGirl> myMap = new MyHashMap<PrettyGirl>();

    myMap.put(prettyGirl, "Java编程技术乐园");

    myMap.put(prettyGirl, "生活在长沙的延安人");

    myMap.put(prettyGirl, "期待和你加入乐园");

    System.out.println("myMap :" + myMap.toString());
}
--打印:---
map :{com.happy.PrettyGirl@3c1=期待和你加入乐园}
myMap :{com.happy.PrettyGirl@3c1=Java编程技术乐园---生活在长沙的延安人---期待和你加入乐园}

总结:要实现开头的需求

1、如果是类似String这种,已经重写了hashCode和equals的。则只需要创建一个自己的HashMap类,重写put即可。

2、如果是自定义的类,那就必须重写了hashCode和equals的,然后在使用自定义的HashMap类了。

具体的代码判断逻辑:

判断key是否存在的时候是先比较key的hashCode,再比较相等或equals的,所以重写hashCode()和equals()方法即可实现添加重复元素。重写这两个方法之后就可以覆盖重复的键值对,如果需要对value进行叠加,调用put()方法之前用containsKey()方法判断是否有重复的键值,如果有,则用get()方法获取原有的value,再加上新加入的value即可。

本文涉及的相关知识:

1、HashMap相关源码

2、== 、equals和 hashCode

3、Hash算法

什么时候需要重写HashCode、equals?为什么重写equals方法时必须重写hashcode方法?

什么时候需要重写HashCode、equals
当我们自定义了对象,并且想要将自定义的对象加到Map中时,我们就必须对自定义的对象重写这两个方法,才能正确使用Map。

为什么重写equals方法时必须重写hashcode方法?

总结: 因为不重写hashcode,创建的每个对象都不相等。就没办法找到相等的两个对象
在我们的业务系统中判断对象时有时候需要的不是一种严格意义上的相等,而是一种业务上的对象相等。在这种情况下,原生的equals方法就不能满足我们的需求了
所以这个时候我们需要重写equals方法,来满足我们的业务系统上的需求。那么为什么在重写equals方法的时候需要重写hashCode方法呢?
我们先来看一下Object.hashCode的通用约定(摘自《Effective Java》第45页)

  1. 在一个应用程序执行期间,如果一个对象的equals方法做比较所用到的信息没有被修改的话,那么,对该对象调用hashCode方法多次,它必须始终如一地返回
    同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不同,即这个应用程序这次执行返回的整数与下一次执行返回的整数可以不一致。
  2. 如果两个对象根据equals(Object)方法是相等的,那么调用这两个对象中任一个对象的hashCode方法必须产生同样的整数结果。
  3. 如果两个对象根据equals(Object)方法是不相等的,那么调用这两个对象中任一个对象的hashCode方法,不要求必须产生不同的整数结果。然而,程序员应该意识到这样的事实,对于不相等的对象产生截然不同的整数结果,有可能提高散列表(hash
    table)的性能。
如果只重写了equals方法而没有重写hashCode方法的话,则会违反约定的第二条: ** 相等的对象必须具有相等的散列码(hashCode)。** 同时对于HashSet和HashMap这些基于散列值(hash)实现的类。HashMap的底层处理机制是以数组的方法保存放入的数据的(Node

常见的误区
看下面这段代码:

import java.util.HashMap;

public class HashCodeEqual {
    public static void main(String[] args) {
        Apple a1 = new Apple("Blue");
        Apple a2 = new Apple("Green");
        
        HashMap map = new HashMap<>();
        map.put(a1, 10);
        map.put(a2, 20);
        
        System.out.println(map.get(new Apple("Green")));
    }
}

class Apple {
    public String color;
    
    public Apple(String color) {
        this.color = color;
    }
    
    @Override
    public boolean equals(Object obj) {
        if(! (obj instanceof Apple))
            return false;
        if(obj == this)
            return true;
        return this.color.equals(((Apple)obj).color);
    }
}

我们执行上面这段代码

在这里插入图片描述

却发现与我们预想的结果并不一样,我们想取出map中颜色为Green的apple,最后却得到一个null值,这说明map没有我们需要的颜色为green的apple对象,但实际上,我们明明向其中添加了一个颜色为green的apple对象,也重写了equals方法,为什么最后却取不出这个对象呢?
![Upload Paste_Image.png failed. Please try again.]
错误出现的原因
这个问题引起的原因是因为我们没有重写“hashCode”方法,这就需要我们深入理解equals方法和hashCode方法的原理:

如果两个对象是相等的,那么他们必须拥有一样的hashcode,这是第一个前提
如果两个对象有一样的hashcode,但仍不一定相等,因为还需要第二个要求,也就是equals方法的判断。
其实,map判断对象的方法就是先判断hashcode是否相等,如果相等再判断equals方法是否返回true,只有同时满足两个条件,最后才会被认为是相等的。
Map查找元素比线性搜索更快,这是因为map利用hashkey去定位元素,这个定位查找的过程分成两步,内部原理中,map将对象存储在类似数组的数组的区域,所以要经过两个查找,先找到hashcode相等的,然后在再在其中按线性搜索使用equals方法,通过这两部来查找一个对象。

JAVA高级面试题——2019_第31张图片

Paste_Image.png

就像上图这个结构,每个hashcode对应一个桶,每个tongli桶里还有多个对象,确定桶的方法是hashCode,在桶中遍历线性查找的方法是equals。
在Object中的默认的hashCode方法的实现是为不同的对象返回不同的hashcode,因此如果我们不重写hashcode方法,那么没有任何两个对象会是相等的,因为object类中的hashcode实现是为不同的对象返回不同的hashcode。
所以,我们就搞清楚了上一段代码出错的原因,由于没有重写hashcode方法,所有的对象都是不一样的,所以我们需要重写hashcode方法,让颜色的对象的hashcode是一样的,比较直接的写法就是直接用color的length作为hashcode。

public int hashCode(){
return this.color.length();
}

JAVA高级面试题——2019_第32张图片

** 切记,一定要同时重写hashCode和equals方法 **

Jdk1.6,JDK1.7,jdk1.8中HashMap区别

一、区别

  1. 最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;

  2. jdk1.7中当哈希表为空时,会先调用inflateTable()初始化一个数组;而1.8则是直接调用resize()扩容;

  3. 插入键值对的put方法的区别,1.8中会将节点插入到链表尾部,而1.7中是采用头插;

  4. jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀;

  5. 扩容时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序;

  6. jdk1.8是扩容时通过hash&cap==0将链表分散,无需改变hash值,而1.7是通过更新hashSeed来修改hash值达到分散的目的;

  7. 扩容策略:1.7中是只要不小于阈值就直接扩容2倍;而1.8的扩容策略会更优化,当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数不小于7就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。
    ————————————————

版权声明:本文为CSDN博主「PANDA」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_38685503/article/details/88430788

HashMap在JDK1.6中的实现方式
put方法:

put方法完成数据的基本写入操作,同时会验证数据的有效性,如key是否为空的处理,计算hash值,hash值 的查找,并根据hash值找出所有的数据,判断是否重复,如果重复,则替换,并返回旧值,如果不重复,则写入数据(addEntry)。

/**
    * Associates the specified value with the specified key in this map.
    * If the map previously contained a mapping for the key, the old
    * value is replaced.
    *
    * @param key key with which the specified value is to be associated
    * @param value value to be associated with the specified key
    * @return the previous value associated with key, or
    *         null if there was no mapping for key.
    *         (A null return can also indicate that the map
    *         previously associated null with key.)
    */
   public V put(K key, V value) {
       if (key == null)
           return putForNullKey(value);
       int hash = hash(key.hashCode());
       int i = indexFor(hash, table.length);
       for (Entry<K,V> e = table[i]; e != null; e = e.next) {
           Object k;
           if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
               V oldValue = e.value;
               e.value = value;
               e.recordAccess(this);
               return oldValue;
           }
       }

       modCount++;
       addEntry(hash, key, value, i);
       return null;
   }

对应的addEntry方法: addEntry方法实现了数据的写入以及判断是否需要对HashMap进行扩容。

/**
 * Adds a new entry with the specified key, value and hash code to
 * the specified bucket.  It is the responsibility of this
 * method to resize the table if appropriate.
 *
 * Subclass overrides this to alter the behavior of put method.
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

resize方法: resize方法主要是对Map进行扩容的实际操作:构造存储结构,并调用数据转移方法transfer进行数据转移

/**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     *
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * This has the effect of preventing future calls.
     *
     * @param newCapacity the new capacity, MUST be a power of two;
     *        must be greater than current capacity unless current
     *        capacity is MAXIMUM_CAPACITY (in which case value
     *        is irrelevant).
     */
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

transfer 方法: 完成数据转移,注意,转移的过程就是两个数组进行数据拷贝的过程,数据产生了倒置,新元素其实是插入到了第一个位置。

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {//每次循环都是将第一个元素指向了最新的数据,其他数据后移(链式存储,更改指向)
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

Entry的构造: 链式存储结构。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;

    /**
     * Creates new entry.
     */
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

  ···
}

HashMap在JDK1.7中的实现
JDK1.7中的实现与JDK1.6中的实现总体来说,基本没有改进,区别不大。

put 方法:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

addEntry方法:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

resize方法:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer方法:

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];
                newTable[i] = e;
                e = next;
            }
        }

createEntry方法:

/**
    * Like addEntry except that this version is used when creating entries
    * as part of Map construction or "pseudo-construction" (cloning,
    * deserialization).  This version needn't worry about resizing the table.
    *
    * Subclass overrides this to alter the behavior of HashMap(Map),
    * clone, and readObject.
    */
   void createEntry(int hash, K key, V value, int bucketIndex) {
       Entry<K,V> e = table[bucketIndex];
       table[bucketIndex] = new Entry<>(hash, key, value, e);
       size++;
   }

HashMap在JDK1.8中的实现
put方法: put方法中与JDK1.6/1.7相比,新增了判断是否是TreeNode的逻辑,TreeNode即为红黑树, 同时添加了冲突的元素个数是否大于等于7的判断(因为第一个是-1,所以还是8个元素),如果冲突的 元素个数超过8个,则构造红黑树(treeifyBin方法),否则还是按照链表方式存储。

/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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();
        afterNodeInsertion(evict);
        return null;
    }

treeifyBin方法: treeifyBin方法主要是将链式存储的冲突的数据转换成树状存储结构

/**
    * Replaces all linked nodes in bin at index for given hash unless
    * table is too small, in which case resizes instead.
    */
   final void treeifyBin(Node<K,V>[] tab, int hash) {
       int n, index; Node<K,V> e;
       if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
           resize();
       else if ((e = tab[index = (n - 1) & hash]) != null) {
           TreeNode<K,V> hd = null, tl = null;
           do {
               TreeNode<K,V> p = replacementTreeNode(e, null);
               if (tl == null)
                   hd = p;
               else {
                   p.prev = tl;
                   tl.next = p;
               }
               tl = p;
           } while ((e = e.next) != null);
           if ((tab[index] = hd) != null)
               hd.treeify(tab);
       }
   }

resize方法: 注意:JDK1.8中resize方法扩容时对链表保持了原有的顺序。

/**
    * Initializes or doubles table size.  If null, allocates in
    * accord with initial capacity target held in field threshold.
    * Otherwise, because we are using power-of-two expansion, the
    * elements from each bin must either stay at same index, or move
    * with a power of two offset in the new table.
    *
    * @return the table
    */
   final Node<K,V>[] resize() {
       Node<K,V>[] oldTab = table;
       int oldCap = (oldTab == null) ? 0 : oldTab.length;
       int oldThr = threshold;
       int newCap, newThr = 0;
       if (oldCap > 0) {
           if (oldCap >= MAXIMUM_CAPACITY) {
               threshold = Integer.MAX_VALUE;
               return oldTab;
           }
           else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
               newThr = oldThr << 1; // double threshold
       }
       else if (oldThr > 0) // initial capacity was placed in threshold
           newCap = oldThr;
       else {               // zero initial threshold signifies using defaults
           newCap = DEFAULT_INITIAL_CAPACITY;
           newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
       }
       if (newThr == 0) {
           float ft = (float)newCap * loadFactor;
           newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                     (int)ft : Integer.MAX_VALUE);
       }
       threshold = newThr;
       @SuppressWarnings({"rawtypes","unchecked"})
           Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
       table = newTab;
       if (oldTab != null) {
           for (int j = 0; j < oldCap; ++j) {
               Node<K,V> e;
               if ((e = oldTab[j]) != null) {
                   oldTab[j] = null;
                   if (e.next == null)
                       newTab[e.hash & (newCap - 1)] = e;
                   else if (e instanceof TreeNode)
                       ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                   else { // preserve order
                       Node<K,V> loHead = null, loTail = null;
                       Node<K,V> hiHead = null, hiTail = null;
                       Node<K,V> next;
                       do {
                           next = e.next;
                           if ((e.hash & oldCap) == 0) {
                               if (loTail == null)
                                   loHead = e;
                               else
                                   loTail.next = e;
                               loTail = e;
                           }
                           else {
                               if (hiTail == null)
                                   hiHead = e;
                               else
                                   hiTail.next = e;
                               hiTail = e;
                           }
                       } while ((e = next) != null);
                       if (loTail != null) {
                           loTail.next = null;
                           newTab[j] = loHead;
                       }
                       if (hiTail != null) {
                           hiTail.next = null;
                           newTab[j + oldCap] = hiHead;
                       }
                   }
               }
           }
       }
       return newTab;

多线程

线程池的原理?

1. 为什么要使用线程池
在实际使用中,线程是很占用系统资源的,如果对线程管理不善很容易导致系统问题。因此,在大多数并发框架中都会使用线程池来管理线程,使用线程池管理线程主要有如下好处:

降低资源消耗. 通过复用已存在的线程和降低线程关闭的次数来尽可能降低系统性能损耗;
提升系统响应速度。通过复用线程,省去创建线程的过程,因此整体上提升了系统的响应速度;
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,因此,需要使用线程池来管理线程。

2. 线程池的工作原理
当一个并发任务提交给线程池,线程池分配线程去执行任务的过程如下图所示:

JAVA高级面试题——2019_第33张图片

从图可以看出,线程池执行所提交的任务过程主要有这样几个阶段:### Synchronized实现原理?

从图可以看出,线程池执行所提交的任务过程主要有这样几个阶段:

先判断线程池中核心线程池所有的线程是否都在执行任务。如果不是,则新创建一个线程执行刚提交的任务,否则,核心线程池中所有的线程都在执行任务,则进入第2步;
判断当前阻塞队列是否已满,如果未满,则将提交的任务放置在阻塞队列中;否则,则进入第3步;
判断线程池中所有的线程是否都在执行任务,如果没有,则创建一个新的线程来执行任务,否则,则交给饱和策略进行处理

3. 线程池的创建
创建线程池主要是ThreadPoolExecutor类来完成,ThreadPoolExecutor的有许多重载的构造方法,通过参数最多的构造方法来理解创建线程池有哪些需要配置的参数。ThreadPoolExecutor的构造方法为:

ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

复制代码
下面对参数进行说明:

corePoolSize:表示核心线程池的大小。当提交一个任务时,如果当前核心线程池的线程个数没有达到corePoolSize,则会创建新的线程来执行所提交的任务,即使当前核心线程池有空闲的线程。如果当前核心线程池的线程个数已经达到了corePoolSize,则不再重新创建线程。如果调用了prestartCoreThread()或者 prestartAllCoreThreads(),线程池创建的时候所有的核心线程都会被创建并且启动。
maximumPoolSize:表示线程池能创建线程的最大个数。如果当阻塞队列已满时,并且当前线程池线程个数没有超过maximumPoolSize的话,就会创建新的线程来执行任务。
keepAliveTime:空闲线程存活时间。如果当前线程池的线程个数已经超过了corePoolSize,并且线程空闲时间超过了keepAliveTime的话,就会将这些空闲线程销毁,这样可以尽可能降低系统资源消耗。
unit:时间单位。为keepAliveTime指定时间单位。
workQueue:阻塞队列。用于保存任务的阻塞队列,关于阻塞队列可以看这篇文章。可以使用ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue, PriorityBlockingQueue。
threadFactory:创建线程的工程类。可以通过指定线程工厂为每个创建出来的线程设置更有意义的名字,如果出现并发问题,也方便查找问题原因。
handler:饱和策略。当线程池的阻塞队列已满和指定的线程都已经开启,说明当前线程池已经处于饱和状态了,那么就需要采用一种策略来处理这种情况。采用的策略有这几种:

AbortPolicy: 直接拒绝所提交的任务,并抛出RejectedExecutionException异常;
CallerRunsPolicy:只用调用者所在的线程来执行任务;
DiscardPolicy:不处理直接丢弃掉任务;
DiscardOldestPolicy:丢弃掉阻塞队列中存放时间最久的任务,执行当前任务

线程池执行逻辑

通过ThreadPoolExecutor创建线程池后,提交任务后执行过程是怎样的,下面来通过源码来看一看。execute方法源码如下:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    /*
     * Proceed in 3 steps:
     *
     * 1. If fewer than corePoolSize threads are running, try to
     * start a new thread with the given command as its first
     * task.  The call to addWorker atomically checks runState and
     * workerCount, and so prevents false alarms that would add
     * threads when it shouldn't, by returning false.
     *
     * 2. If a task can be successfully queued, then we still need
     * to double-check whether we should have added a thread
     * (because existing ones died since last checking) or that
     * the pool shut down since entry into this method. So we
     * recheck state and if necessary roll back the enqueuing if
     * stopped, or start a new thread if there are none.
     *
     * 3. If we cannot queue task, then we try to add a new
     * thread.  If it fails, we know we are shut down or saturated
     * and so reject the task.
     */
    int c = ctl.get();
	//如果线程池的线程个数少于corePoolSize则创建新线程执行当前任务
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
	//如果线程个数大于corePoolSize或者创建线程失败,则将任务存放在阻塞队列workQueue中
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
	//如果当前任务无法放进阻塞队列中,则创建新的线程来执行任务
    else if (!addWorker(command, false))
        reject(command);
}

复制代码
ThreadPoolExecutor的execute方法执行逻辑请见注释。下图为ThreadPoolExecutor的execute方法的执行示意图:
JAVA高级面试题——2019_第34张图片
execute方法执行逻辑有这样几种情况:

  1. 如果当前运行的线程少于corePoolSize,则会创建新的线程来执行新的任务;
  2. 如果运行的线程个数等于或者大于corePoolSize,则会将提交的任务存放到阻塞队列workQueue中;
  3. 如果当前workQueue队列已满的话,则会创建新的线程来执行任务;
  4. 如果线程个数已经超过了maximumPoolSize,则会使用饱和策略RejectedExecutionHandler来进行处理。

需要注意的是,线程池的设计思想就是使用了核心线程池corePoolSize,阻塞队列workQueue和线程池maximumPoolSize,这样的缓存策略来处理任务,实际上这样的设计思想在需要框架中都会使用。
4. 线程池的关闭
关闭线程池,可以通过shutdown和shutdownNow这两个方法。它们的原理都是遍历线程池中所有的线程,然后依次中断线程。shutdown和shutdownNow还是有不一样的地方:

  1. shutdownNow首先将线程池的状态设置为STOP,然后尝试停止所有的正在执行和未执行任务的线程,并返回等待执行任务的列表;
  2. shutdown只是将线程池的状态设置为SHUTDOWN状态,然后中断所有没有正在执行任务的线程

可以看出shutdown方法会将正在执行的任务继续执行完,而shutdownNow会直接中断正在执行的任务。调用了这两个方法的任意一个,isShutdown方法都会返回true,当所有的线程都关闭成功,才表示线程池成功关闭,这时调用isTerminated方法才会返回true。
5. 如何合理配置线程池参数?
要想合理的配置线程池,就必须首先分析任务特性,可以从以下几个角度来进行分析:

  1. 任务的性质:CPU密集型任务,IO密集型任务和混合型任务。
  2. 任务的优先级:高,中和低。
  3. 任务的执行时间:长,中和短。
  4. 任务的依赖性:是否依赖其他系统资源,如数据库连接。

任务性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务配置尽可能少的线程数量,如配置Ncpu+1个线程的线程池。IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程,如2xNcpu。混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU。
并且,阻塞队列最好是使用有界队列,如果采用无界队列的话,一旦任务积压在阻塞队列中的话就会占用过多的内存资源,甚至会使得系统崩溃。

Synchronized原理

synchronized的底层是使用操作系统的mutex lock实现的。

  • 内存可见性:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的。
  • 操作原子性:持有同一个锁的两个同步块只能串行地进入

锁的内存语义:

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

锁释放和锁获取的内存语义:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息
    JAVA高级面试题——2019_第35张图片
    synchronized锁
    synchronized用的锁是存在Java对象头里的。

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。代码块同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。

根据虚拟机规范的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;相应地,在执行monitorexit指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

注意两点:

1、synchronized同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

2、同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

Mutex Lock
监视器锁(Monitor)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。

mutex的工作方式:

JAVA高级面试题——2019_第36张图片

  1. 申请mutex
  2. 如果成功,则持有该mutex
  3. 如果失败,则进行spin自旋. spin的过程就是在线等待mutex, 不断发起mutex gets, 直到获得mutex或者达到spin_count限制为止
  4. 依据工作模式的不同选择yiled还是sleep
  5. 若达到sleep限制或者被主动唤醒或者完成yield, 则重复1)~4)步,直到获得为止
    由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。在JDK1.6中,虚拟机进行了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中:

synchronized与java.util.concurrent包中的ReentrantLock相比,由于JDK1.6中加入了针对锁的优化措施(见后面),使得synchronized与ReentrantLock的性能基本持平。ReentrantLock只是提供了synchronized更丰富的功能,而不一定有更优的性能,所以在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

Java对象头

JAVA高级面试题——2019_第37张图片在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化,以32位的JDK为例:

JAVA高级面试题——2019_第38张图片锁优化
偏向锁、轻量级锁、重量级锁

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

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。

偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁是为了在只有一个线程执行同步块时提高性能。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

偏向锁获取过程:

(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争(CAS获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safepoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
(5)执行同步代码。
偏向锁的释放过程:

如上步骤(4)。偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

关闭偏向锁:

偏向锁在Java 6和Java 7里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁
轻量级锁是为了在线程近乎交替执行同步块时提高性能。

轻量级锁的加锁过程:

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示。

JAVA高级面试题——2019_第39张图片(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
JAVA高级面试题——2019_第40张图片

(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁的解锁过程:

(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
重量级锁
如上轻量级锁的加锁过程步骤(5),轻量级锁所适应的场景是线程近乎交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。Mark Word的锁标记位更新为10,Mark Word指向互斥量(重量级锁)

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

(具体见前面的mutex lock)

偏向锁、轻量级锁、重量级锁之间转换

JAVA高级面试题——2019_第41张图片

JAVA高级面试题——2019_第42张图片偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
  • 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。
    但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

其他锁优化
锁消除

锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。

根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

看下面这段程序:

public class SynchronizedTest {

    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();

        for (int i = 0; i < 100000000; i++) {
            test.append("abc", "def");
        }
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去(即StringBuffer sb的引用没有传递到该方法外,不可能被其他线程拿到该引用),所以其实这过程是线程安全的,可以将锁消除。

锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

举个例子:

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

自旋锁与自适应自旋锁

  • 引入自旋锁的原因:互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

  • 自旋锁:让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK
    1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启;在JDK1.6中默认开启。

  • 自旋锁的缺点:自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环10次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数-XX:PreBlockSpin可以调整自旋次数,默认的自旋次数为10。

  • 自适应的自旋锁:JDK1.6引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

  • 自旋锁使用场景:从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。(见前面“轻量级锁”)

总结
synchronized特点:保证内存可见性、操作原子性
synchronized影响性能的原因:
1、加锁解锁操作需要额外操作;
2、互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态与内核态的切换的性能代价是比较大的)
synchronized锁:对象头中的Mark Word根据锁标志位的不同而被复用

  • 偏向锁:在只有一个线程执行同步块时提高性能。Mark
    Word存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单比较ThreadID。特点:只有等到线程竞争出现才释放偏向锁,持有偏向锁的线程不会主动释放偏向锁。之后的线程竞争偏向锁,会先检查持有偏向锁的线程是否存活,如果不存货,则对象变为无锁状态,重新偏向;如果仍存活,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁
  • 轻量级锁:在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象目前的Mark Word到栈帧的Lock
    Record,若拷贝成功:虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock
    record里的owner指针指向对象的Mark
    Word。若拷贝失败:若当前只有一个等待线程,则可通过自旋稍微等待一下,可能持有轻量级锁的线程很快就会释放锁。
    但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁
  • 重量级锁:指向互斥量(mutex),底层通过操作系统的mutex
    lock实现。等待锁的线程会被阻塞,由于Linux下Java线程与操作系统内核态线程一一映射,所以涉及到用户态和内核态的切换、操作系统内核态中的线程的阻塞和恢复。

Synchronized与lock区别?

并发编程的锁机制:synchronized和lock
并发编程中,锁是经常需要用到的,今天我们一起来看下Java中的锁机制:synchronized和lock。

  1. 锁的种类
    锁的种类挺多,包括:自旋锁、自旋锁的其他种类、阻塞锁、可重入锁、读写锁、互斥锁、悲观锁、乐观锁、公平锁、可重入锁等等,其余就不列出了。我们这边重点看如下几种:可重入锁、读写锁、可中断锁、公平锁。

1.1 可重入锁
如果锁具备可重入性,则称作为可重入锁。synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。举比如说,当一个线程执行到method1 的synchronized方法时,而在method1中会调用另外一个synchronized方法method2,此时该线程不必重新去申请锁,而是可以直接执行方法method2。

1.2 读写锁
读写锁将对一个资源的访问分成了2个锁,如文件,一个读锁和一个写锁。正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。

1.3 可中断锁
可中断锁,即可以中断的锁。在Java中,synchronized就不是可中断锁,而Lock是可中断锁。 如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。

Lock接口中的lockInterruptibly()方法就体现了Lock的可中断性。

1.4 公平锁
公平锁即尽量以请求锁的顺序来获取锁。同时有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁,这种就是公平锁。

非公平锁即无法保证锁的获取是按照请求锁的顺序进行的,这样就可能导致某个或者一些线程永远获取不到锁。

synchronized是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentrantLock和ReentrantReadWriteLock,默认情况下是非公平锁,但是可以设置为公平锁。

  1. synchronized和lock的用法
    2.1 synchronized
    synchronized是Java的关键字,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。简单总结如下四种用法。

2.1.1 代码块
对某一代码块使用,synchronized后跟括号,括号里是变量,一次只有一个线程进入该代码块。

public int synMethod(int m){
    synchronized(m) {
     //...
    }
 }

2.1.2 方法声明时
方法声明时使用,放在范围操作符之后,返回类型声明之前。即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候。

public synchronized void synMethod() {
   //...
}

2.1.3 synchronized后面括号里是对象
synchronized后面括号里是一对象,此时线程获得的是对象锁。

public void test() {
  synchronized (this) {
      //...
  }
}

2.1.4 synchronized后面括号里是类
synchronized后面括号里是类,如果线程进入,则线程在该类中所有操作不能进行,包括静态变量和静态方法,对于含有静态方法和静态变量的代码块的同步,通常使用这种方式。

2.2 Lock
Lock接口主要相关的类和接口如下。

JAVA高级面试题——2019_第43张图片Lock
Lock
ReadWriteLock是读写锁接口,其实现类为ReetrantReadWriteLock。ReetrantLock实现了Lock接口。

2.2.1 Lock
Lock中有如下方法:

public interface Lock {
	void lockInterruptibly() throws InterruptedException;  
	boolean tryLock();  
	boolean tryLock(long time, TimeUnit unit) throws InterruptedException;  
	void unlock();  
	Condition newCondition();
}
  • lock:用来获取锁,如果锁被其他线程获取,处于等待状态。如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。
  • lockInterruptibly:通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。
  • tryLock:tryLock方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。
  • tryLock(long,TimeUnit):与tryLock类似,只不过是有等待时间,在等待时间内获取到锁返回true,超时返回false。
  • unlock:释放锁,一定要在finally块中释放

2.2.2 ReetrantLock
实现了Lock接口,可重入锁,内部定义了公平锁与非公平锁。默认为非公平锁:

public ReentrantLock() {  
  sync = new NonfairSync();  
} 

可以手动设置为公平锁:

public ReentrantLock(boolean fair) {  
  sync = fair ? new FairSync() : new NonfairSync();  
}  

2.2.3 ReadWriteLock

public interface ReadWriteLock {  
    Lock readLock();       //获取读锁  
    Lock writeLock();      //获取写锁  
}  

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。ReentrantReadWirteLock实现了ReadWirteLock接口,并未实现Lock接口。 不过要注意的是:

如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。

如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

2.2.4 ReetrantReadWriteLock
ReetrantReadWriteLock同样支持公平性选择,支持重进入,锁降级。

public class RWLock {
    static Map<String, Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    static Lock r = rwLock.readLock();
    static Lock w = rwLock.writeLock();
    //读
    public static final Object get(String key){
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }
    //写
    public static final Object put(String key, Object value){
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }
}

只需在读操作时获取读锁,写操作时获取写锁。当写锁被获取时,后续的读写操作都会被阻塞,写锁释放后,所有操作继续执行。

3. 两种锁的比较
3.1 synchronized和lock的区别
Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。
3.2 性能比较
下面对synchronized与Lock进行性能测试,分别开启10个线程,每个线程计数到1000000,统计两种锁同步所花费的时间。网上也能找到这样的例子。

public class TestAtomicIntegerLock {

    private static int synValue;

    public static void main(String[] args) {
        int threadNum = 10;
        int maxValue = 1000000;
        testSync(threadNum, maxValue);
        testLocck(threadNum, maxValue);
    }
	//test synchronized
    public static void testSync(int threadNum, int maxValue) {
        Thread[] t = new Thread[threadNum];
        Long begin = System.nanoTime();
        for (int i = 0; i < threadNum; i++) {
            Lock locks = new ReentrantLock();
            synValue = 0;
            t[i] = new Thread(() -> {

                for (int j = 0; j < maxValue; j++) {
                    locks.lock();
                    try {
                        synValue++;
                    } finally {
                        locks.unlock();
                    }
                }

            });
        }
        for (int i = 0; i < threadNum; i++) {
            t[i].start();
        }
        //main线程等待前面开启的所有线程结束
        for (int i = 0; i < threadNum; i++) {
            try {
                t[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("使用lock所花费的时间为:" + (System.nanoTime() - begin));
    }
	// test Lock
    public static void testLocck(int threadNum, int maxValue) {
        int[] lock = new int[0];
        Long begin = System.nanoTime();
        Thread[] t = new Thread[threadNum];
        for (int i = 0; i < threadNum; i++) {
            synValue = 0;
            t[i] = new Thread(() -> {
                for (int j = 0; j < maxValue; j++) {
                    synchronized(lock) {
                        ++synValue;
                    }
                }
            });
        }
        for (int i = 0; i < threadNum; i++) {
            t[i].start();
        }
        //main线程等待前面开启的所有线程结束
        for (int i = 0; i < threadNum; i++) {
            try {
                t[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("使用synchronized所花费的时间为:" + (System.nanoTime() - begin));
    }

}

测试结果的差异还是比较明显的,Lock的性能明显高于synchronized。本次测试基于JDK1.8。

使用lock所花费的时间为:436667997
使用synchronized所花费的时间为:616882878

JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的Lock对象,性能更高一些。多线程环境下,synchronized的吞吐量下降的非常严重,而ReentrankLock则能基本保持在同一个比较稳定的水平上。

到了JDK1.6,发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

4. 总结
本文主要对并发编程中的锁机制synchronized和lock,进行详解。synchronized是基于JVM实现的,内置锁,Java中的每一个对象都可以作为锁。对于同步方法,锁是当前实例对象。对于静态同步方法,锁是当前对象的Class对象。对于同步方法块,锁是Synchonized括号里配置的对象。Lock是基于在语言层面实现的锁,Lock锁可以被中断,支持定时锁。Lock可以提高多个线程进行读操作的效率。通过对比得知,Lock的效率是明显高于synchronized关键字的,一般对于数据结构设计或者框架的设计都倾向于使用Lock而非Synchronized。

volatile原理

1、引言

在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。它在某些情况下比synchronized的开销更小,下面我们将深入分析Voliate的实现原理。

2、Volatile定义

java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

即一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。保证可见性、不保证原子性。
(2)禁止进行指令重排序。

关于这两层语义的详细情况,如果不是很理解,可以看博文:并发编程——原子性,可见性和有序性

3、为什么要使用Volatile

Volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

4、Voliate保证可见性

先看一段代码,假如线程1先执行,线程2后执行:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}

//线程2
stop = true;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。下面解释一下这段代码为何有可能导致无法中断线程。

前面文章 Java内存模型 中我们说过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了:

第一:使用volatile关键字会强制将修改的值立即写入主存;

第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。那么线程1读取到的就是最新的正确的值。

5、volatile不能保证原子性

Volatile不保证对变量的操作是原子性,下面看一个例子:

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

上面程序的输出有人认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。可能有人就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

(1)假如某个时刻变量inc的值为10,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

(2)然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

(3)然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

(4)那么两个线程分别进行了一次自增操作后,inc只增加了1。

解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。把上面的代码改成以下任何一种都可以达到效果:

5.1 采用synchronized

public class Test {
    public  int inc = 0;

    public synchronized void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

5.2 采用Lock

public class Test {
    public  int inc = 0;
    Lock lock = new ReentrantLock();

    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

5.3 采用AtomicInteger

public class Test {
    public  AtomicInteger inc = new AtomicInteger();

    public  void increase() {
        inc.getAndIncrement();
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

6、volatile一定程度保证有序性

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。volatile关键字禁止指令重排序有两层意思:

(1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

(2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

可能上面说的比较绕,举个简单的例子:

//x、y为非volatile变量
//flag为volatile变量

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。那么我们回到前面举的一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么就可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

7、volatile的实现机制和原理

7.1 实现机制

前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。

Java代码: instance = new Singleton();//instance是volatile变量
汇编代码:  0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

(2)它会强制将对缓存的修改操作立即写入主内存;

(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

7.2 实现原理

7.2.1 可见性

处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。

如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。

但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

7.2.2 有序性

Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

8、使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

(1)对变量的写操作不依赖于当前值
(2)该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入volatile变量的这些有效值独立于任何程序的状态,包括变量的当前状态。即实际就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。下面列举Java中使用volatile的几个场景。

8.1 状态标记量

volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            

//线程2:
while(!inited ){
    sleep();
}
doSomethingwithconfig(context);

8.2 double check(单例模式)

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance == null) {
            synchronized (Singleton.class) {
                if(instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

这里为什么要使用volatile修饰instance?主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在JVM中这句话大概做了下面3件事情:

(1)给instance分配内存

(2)调用Singleton的构造函数来初始化成员变量

(3)将instance对象指向分配的内存空间(执行完这步instance就为非null了)。

但是在JVM的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在3执行完毕、2未执行之前,被线程二抢占了,这时instance已经是非null了(但却没有初始化),所以线程二会直接返回instance,然后使用,然后顺理成章地报错。

9、Volatile的使用优化

在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。追加字节能优化性能?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。

/** head of the queue */
private transient final PaddedAtomicReference<QNode> head;

/** tail of the queue */
private transient final PaddedAtomicReference<QNode> tail;

static final class PaddedAtomicReference <T> extends AtomicReference <T> {

    // enough padding for 64bytes with 4byte refs
    Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;

    PaddedAtomicReference(T r) {
        super(r);
    }
}

public class AtomicReference <V> implements java.io.Serializable {

    private volatile V value;
  //省略其他代码
}

为什么追加64字节能够提高并发编程的效率呢? 因为对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作是需要不停修改头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。

那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。

(1)缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
(2)共享变量不会被频繁的写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,共享变量如果不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定

Java中常用的锁机制有那些?

一、公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

二、可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。

对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

synchronized void setA() throws Exception{

    Thread.sleep(1000);

    setB();

}

synchronized void setB() throws Exception{

    Thread.sleep(1000);

}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。

三、独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

对于Synchronized而言,当然是独享锁。

四、互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock

读写锁在Java中的具体实现就是ReadWriteLock

五、乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在Java中的使用,就是利用各种锁。

乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

六、分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

七、偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

八、自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

典型的自旋锁实现的例子,可以参考自旋锁的实现

数据库

Mysql数据库的优化技术

对mysql优化时一个综合性的技术,主要包括
a: 表的设计合理化(符合3NF)
b: 添加适当索引(index) [四种: 普通索引、主键索引、唯一索引unique、全文索引]
c: 分表技术(水平分割、垂直分割)
d: 读写[写: update/delete/add]分离
e: 存储过程 [模块化编程,可以提高速度]
f: 对mysql配置优化 [配置最大并发数my.ini, 调整缓存大小 ]
g: mysql服务器硬件升级
h: 定时的去清除不需要的数据,定时进行碎片整理(MyISAM)

Sql优化

1.对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。

2.应尽量避免在 where 子句中对字段进行 null 值判断,否则将导致引擎放弃使用索引而进行全表扫描,如:
select id from t where num is null
可以在num上设置默认值0,确保表中num列没有null值,然后这样查询:
select id from t where num=0

3.应尽量避免在 where 子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描。

4.应尽量避免在 where 子句中使用 or 来连接条件,否则将导致引擎放弃使用索引而进行全表扫描,如:
select id from t where num=10 or num=20
可以这样查询:
select id from t where num=10
union all
select id from t where num=20

5.in 和 not in 也要慎用,否则会导致全表扫描,如:
select id from t where num in(1,2,3)
对于连续的数值,能用 between 就不要用 in 了:
select id from t where num between 1 and 3

6.下面的查询也将导致全表扫描:
select id from t where name like ‘%abc%’

7.应尽量避免在 where 子句中对字段进行表达式操作,这将导致引擎放弃使用索引而进行全表扫描。如:
select id from t where num/2=100
应改为:
select id from t where num=100*2

8.应尽量避免在where子句中对字段进行函数操作,这将导致引擎放弃使用索引而进行全表扫描。如:
select id from t where substring(name,1,3)=‘abc’–name以abc开头的id
应改为:
select id from t where name like ‘abc%’

9.不要在 where 子句中的“=”左边进行函数、算术运算或其他表达式运算,否则系统将可能无法正确使用索引。

10.在使用索引字段作为条件时,如果该索引是复合索引,那么必须使用到该索引中的第一个字段作为条件时才能保证系统使用该索引,
否则该索引将不会被使用,并且应尽可能的让字段顺序与索引顺序相一致。

11.不要写一些没有意义的查询,如需要生成一个空表结构:
select col1,col2 into #t from t where 1=0
这类代码不会返回任何结果集,但是会消耗系统资源的,应改成这样:
create table #t(…)

12.很多时候用 exists 代替 in 是一个好的选择:
select num from a where num in(select num from b)
用下面的语句替换:
select num from a where exists(select 1 from b where num=a.num)

13.并不是所有索引对查询都有效,SQL是根据表中数据来进行查询优化的,当索引列有大量数据重复时,SQL查询可能不会去利用索引,
如一表中有字段sex,male、female几乎各一半,那么即使在sex上建了索引也对查询效率起不了作用。

14.索引并不是越多越好,索引固然可以提高相应的 select 的效率,但同时也降低了 insert 及 update 的效率,
因为 insert 或 update 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。
一个表的索引数最好不要超过6个,若太多则应考虑一些不常使用到的列上建的索引是否有必要。

15.尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。
这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。

16.尽可能的使用 varchar 代替 char ,因为首先变长字段存储空间小,可以节省存储空间,
其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

17.任何地方都不要使用 select * from t ,用具体的字段列表代替“*”,不要返回用不到的任何字段。

18.避免频繁创建和删除临时表,以减少系统表资源的消耗。

19.临时表并不是不可使用,适当地使用它们可以使某些例程更有效,例如,当需要重复引用大型表或常用表中的某个数据集时。但是,对于一次性事件,最好使用导出表。

20.在新建临时表时,如果一次性插入数据量很大,那么可以使用 select into 代替 create table,避免造成大量 log ,
以提高速度;如果数据量不大,为了缓和系统表的资源,应先create table,然后insert。

21.如果使用到了临时表,在存储过程的最后务必将所有的临时表显式删除,先 truncate table ,然后 drop table ,这样可以避免系统表的较长时间锁定。

22.尽量避免使用游标,因为游标的效率较差,如果游标操作的数据超过1万行,那么就应该考虑改写。

23.使用基于游标的方法或临时表方法之前,应先寻找基于集的解决方案来解决问题,基于集的方法通常更有效。

24.与临时表一样,游标并不是不可使用。对小型数据集使用 FAST_FORWARD 游标通常要优于其他逐行处理方法,尤其是在必须引用几个表才能获得所需的数据时。
在结果集中包括“合计”的例程通常要比使用游标执行的速度快。如果开发时间允许,基于游标的方法和基于集的方法都可以尝试一下,看哪一种方法的效果更好。
25.尽量避免大事务操作,提高系统并发能力。26.尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理。

Mysql如何做高可用?

第一种:主从复制+读写分离
客户端通过Master对数据库进行写操作,slave端进行读操作,并可进行备份。Master出现问题后,可以手动将应用切换到slave端。
JAVA高级面试题——2019_第44张图片
对于数据实时性要求不是特别严格的应用,只需要通过廉价的pc server来扩展Slave的数量,将读压力分散到多台Slave的机器上面,即可通过分散单台数据库服务器的读压力来解决数据库端的读性能瓶颈,毕竟在大多数数据库应用系统中的读压力要比写压力大的多。这在很大程度上解决了目前很多中小型网站的数据库压力瓶颈问题,甚至有些大型网站也在使用类似的方案解决数据库瓶颈问题。

第二种:Mysql Cluster
JAVA高级面试题——2019_第45张图片
MySQL Cluster 由一组计算机构成,每台计算机上均运行着多种进程,包括 MySQL 服务器,NDB Cluster的数据节点,管理服务器,以及(可能)专门的数据访问程序。
由于MySQL Cluster架构复杂,部署费时(通常需要DBA几个小时的时间才能完成搭建),而依靠 MySQL Cluster Manager 只需一个命令即可完成,但 MySQL Cluster Manager 是收费的。并且业内资深人士认为NDB 不适合大多数业务场景,而且有安全问题。因此,使用的人数较少。

第三种:Heartbeat+双主从复制
JAVA高级面试题——2019_第46张图片
heartbeat 是 Linux-HA 工程的一个组件,heartbeat 最核心的包括两个部分:心跳监测和资源接管。在指定的时间内未收到对方发送的报文,那么就认为对方失效,这时需启动资源接管模块来接管运 行在对方主机上的资源或者服务

第四种:HeartBeat+DRBD+Mysql

JAVA高级面试题——2019_第47张图片
DRBD 是通过网络来实现块设备的数据镜像同步的一款开源 Cluster 软件,它自动完成网络中两个不同服务
器上的磁盘同步,相对于 binlog 日志同步,它是更底层的磁盘同步,理论上 DRDB 适合很多文件型系统的高可
用。

第五种:Lvs+keepalived+双主复制
JAVA高级面试题——2019_第48张图片
Lvs 是一个虚拟的服务器集群系统,可以实现 LINUX 平台下的简单负载均衡。keepalived 是一个类似于
layer3, 4 & 5 交换机制的软件,主要用于主机与备机的故障转移,这是一种适用面很广的负载均衡和高可用方
案,最常用于 Web 系统。

第六种:MariaDB Galera
JAVA高级面试题——2019_第49张图片

MariaDB Galera Cluster 是一套在mysql innodb存储引擎上面实现multi-master及数据实时同步的系统架构,业务层面无需做读写分离工作,数据库读写压力都能按照既定的规则分发到 各个节点上去。在数据方面完全兼容 MariaDB 和 MySQL。
该架构主要有以下几种特性:
(1).同步复制 Synchronous replication
(2).Active-active multi-master 拓扑逻辑
(3).可对集群中任一节点进行数据读写
(4).自动成员控制,故障节点自动从集群中移除
(5).自动节点加入
(6).真正并行的复制,基于行级
(7).直接客户端连接,原生的 MySQL 接口
(8).每个节点都包含完整的数据副本
(9).多台数据库中数据同步由 wsrep 接口实现
其局限性体现在以下几点:
(1).目前的复制仅仅支持InnoDB存储引擎,任何写入其他引擎的表,包括mysql.*表将不会复制,但是DDL语句会被复制的,因此创建用户将会被复制,但是insert into mysql.user…将不会被复制的.
(2).DELETE操作不支持没有主键的表,没有主键的表在不同的节点顺序将不同,如果执行SELECT…LIMIT… 将出现不同的结果集.
(3).在多主环境下LOCK/UNLOCK TABLES不支持,以及锁函数GET_LOCK(), RELEASE_LOCK()…
(4).查询日志不能保存在表中。如果开启查询日志,只能保存到文件中。
(5).允许最大的事务大小由wsrep_max_ws_rows和wsrep_max_ws_size定义。任何大型操作将被拒绝。如大型的LOAD DATA操作。
(6).由于集群是乐观的并发控制,事务commit可能在该阶段中止。如果有两个事务向在集群中不同的节点向同一行写入并提交,失败的节点将中止。对 于集群级别的中止,集群返回死锁错误代码(Error: 1213 SQLSTATE: 40001 (ER_LOCK_DEADLOCK)).
(7).XA事务不支持,由于在提交上可能回滚。
(8).整个集群的写入吞吐量是由最弱的节点限制,如果有一个节点变得缓慢,那么整个集群将是缓慢的。为了稳定的高性能要求,所有的节点应使用统一的硬件。
(9).集群节点建议最少3个。
(10).如果DDL语句有问题将破坏集群。

———————————————— 版权声明:本文为CSDN博主「爱上双眼皮儿的猫」的原创文章,遵循 CC 4.0 BY-SA
版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u013399093/article/details/70597712

Mysql存储原理?

MySQL 的常用引擎
1. InnoDB
InnoDB 的存储文件有两个,后缀名分别是 .frm 和 .idb,其中 .frm 是表的定义文件,而 idb 是数据文件。

InnoDB 中存在表锁和行锁,不过行锁是在命中索引的情况下才会起作用。

InnoDB 支持事务,且支持四种隔离级别(读未提交、读已提交、可重复读、串行化),默认的为可重复读;而在 Oracle 数据库中,只支持串行化级别和读已提交这两种级别,其中默认的为读已提交级别。

2. Myisam
Myisam 的存储文件有三个,后缀名分别是 .frm、.MYD、MYI,其中 .frm 是表的定义文件,.MYD 是数据文件,.MYI 是索引文件。

Myisam 只支持表锁,且不支持事务。Myisam 由于有单独的索引文件,在读取数据方面的性能很高 。

3. 存储结构
InnoDB 和 Myisam 都是用 B+Tree 来存储数据的。

MySQL 的数据、索引存储结构
1. 数据存储的原理(硬盘)
信息存储在硬盘里,硬盘是由很多的盘片组成,通过盘片表面的磁性物质来存储数据。

把盘片放在显微镜下放大,可以看到盘片表面是凹凸不平的,凸起的地方被磁化,代表数字 1,凹的地方没有被磁化,代表数字 0,因此硬盘可以通过二进制的形式来存储表示文字、图片等的信息。

硬盘有很多种,但是都是由盘片、磁头、盘片主轴、控制电机、磁头控制器、数据转换器、接口、缓存等几个部分组成。

所有的盘片都固定在一个旋转轴上,这个轴即盘片主轴。

所有的盘片之间是绝对平行的,在每个盘片的盘面上都有一个磁头,磁头与盘片之间的距离比头发丝的直径还小。

所有的磁头连在一个磁头控制器上,由磁头控制器负责各个磁头的运动,磁头可沿盘片的半径方向移动,实际上是斜切运动,每个磁头同一时刻必须是同轴的,即从正上方往下看,所有磁头任何时候都是重叠的。

由于技术的发展,目前已经有多磁头独立技术了,在此不考虑此种情况。

盘片以每分钟数千转到上万转的速度在高速运转,这样磁头就能对盘片上的指定位置进行数据的读写操作。

由于硬盘是高精密设备,尘埃是其大敌,所以必须完全密封。

2. 数据读写的原理
硬盘在逻辑上被划分为磁道、柱面以及扇区。

磁头靠近主轴接触的表面,即线速度最小的地方,是一个特殊的区域,它不存放任何数据,称为启停区或者着陆区,启停区外就是数据区。

在最外圈,离主轴最远的地方是 “0” 磁道,硬盘数据的存放就是从最外圈开始的。

在硬盘中还有一个叫 “0” 磁道检测器的构件,它是用来完成硬盘的初始定位。

盘面

硬盘的盘片一般用铝合金材料做基片,硬盘的每一个盘片都有上下两个盘面,一般每个盘面都会得到利用,都可以存储数据,成为有效盘面,也有极个别的硬盘盘面数为单数。

每一个这样的有效盘面都有一个盘面号,按顺序从上至下从 0 开始编号。

在硬盘系统中,盘面号又叫磁头号,因为每一个有效盘面都有一个对应的读写磁头,硬盘的盘片组在 2-14 片不等,通常有 2-3 个盘片。

磁道

磁盘在格式化时被划分成许多同心圆,这些同心圆轨迹叫做磁道。

磁道从外向内从 0 开始顺序编号,硬盘的每一个盘面有 300-1024 个磁道,新式大容量硬盘每面的磁道数更多,信息以脉冲串的形式记录在这些轨迹中,这些同心圆不是连续记录数据,而是被划分成一段段的圆弧。

这些圆弧的角速度一样,由于径向长度不一样,所以线速度也不一样,外圈的线速度较内圈的线速度大,即同样的转速度下,外圈在同样时间段里,划过的圆弧长度要比内圈划过的圆弧长度大。

每段圆弧叫做一个扇区,扇区从 1 开始编号,每个扇区中的数据作为一个单元同时读出或写入。

磁道是看不见的,只是盘面上以特殊形式磁化了的一些磁化区,在磁盘格式化时就已规划完毕。

柱面

所有盘面上的同一磁道构成一个圆柱,通常称作柱面。

每个圆柱上的磁头由上而下从 0 开始编号,数据的读 / 写按柱面进行,即磁头读 / 写数据时首先在同一柱面内从 0 磁头开始进行操作,依次向下在同一柱面的不同盘面即磁头上进行操作。

只有在同一柱面所有的磁头全部读 / 写完毕后磁头才转移到下一柱面(同心圆再往里的柱面),因为选取磁头只需要通过电子切换即可,而选取柱面则必须机械切换,电子切换相当快,比在机械上的磁头向邻近磁道移动快得多。

所以,数据的读 / 写按柱面进行,而不按盘面进行,也就是说,一个磁道写满数据后,就在同一柱面的下一个盘面来写,一个柱面写满后,才移到下一个扇区开始写数据,读数据也按照这种方式进行,这样就提高了硬盘的读 / 写效率。

扇区

操作系统以扇区形式将信息存储在硬盘上,每个扇区包括 512 个字节的数据和一些其他信息,一个扇区有两个主要部分:存储数据地点的标识符和存储数据的数据段。

标识符就是扇区头标,包括组成扇区三维地址的三个数字:盘面号,柱面号,扇区号(块号)。

数据段可分为数据和保护数据的纠错码(ECC)。在初始准备期间,计算机用 512 个虚拟信息字节(实际数据的存放地)和与这些虚拟信息字节相应的 ECC 数字填入这个部分。

3. 访盘请求完成过程
1)确定磁盘地址(柱面号,磁头号,扇区号),内存地址(源 / 目):

当需要从磁盘读取数据的时候,系统会将数据的逻辑地址传递个磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。

2)为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点:

A. 首先必须找到柱面,即磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间。

B. 然后目标扇区旋转到磁头下,即磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。

3)即一次访盘请求(读 / 写)完成过程由三个动作组成:

A. 寻道(时间):磁头移动定位到指定磁道。

B. 旋转延迟(时间):等待指定扇区从磁头下旋转经过。

C. 数据传输(时间):数据在磁盘与内存之间的实际传输。

4. 磁盘的读写原理
系统将文件存储到磁盘上时,按柱面、磁头、扇区的方式进行,即最先是第 1 磁道的第一磁头下的所有扇区,然后是同一柱面的下一个磁头……

一个柱面存储满后就推进到下一个柱面,直到把文件内容全部写入磁盘。

系统也以相同的顺序读出数据,读出数据时通过告诉磁盘控制器要读出扇区所在柱面号、磁头号和扇区号(物理地址的三个组成部分)进行。

5. 减少 I/O 的预读原理
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费的时间,磁盘的存取速度往往是主存的几百分之一。

因此,为了提高效率,要尽量减少磁盘的 I/O。

磁盘往往不是严格地按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。

这样做的理论依据是计算机科学中著名的局部性原理:

当一个数据被用到时,其附近的数据一般来说也会被马上使用。

程序运行期间所需要的数据通常比较集中。

由于磁盘顺序读取的效率很高(不需要寻道时间,只需要很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高 I/O 效率。

预读的长度一般为页(Page)的整数倍。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储分割为连续的大小相等的块。

每个存储块称为一页(在许多操作系统中,页的大小通常为 4k),主存和磁盘以页为单位交换数据,当程序要读取的数据不在主存中时,会触发一个缺页异常。

此时系统会向磁盘发出读盘信息,磁盘会找到数据的起始位置并向后连续读取一页或几页的数据载入内存中,然后异常返回,程序继续运行。

6. MySQL 的索引
索引是一种用来实现 MySQL 高效获取数据的数据结构。

我们通常所说的在某个字段上建索引,意思就是让 MySQL 对该字段以索引这种数据结构来存储,然后查找的时候就有对应的查找算法。

建索引的根本目的是为了查找的优化,特别是当数据很庞大的时候,一般的查找算法有顺序查找、折半查找、快速查找等。

但是每种查找算法都只能应用于特定的数据结构之上,例如顺序查找依赖于顺序结构,折半查找通过二叉查找树或红黑树实现二分搜索。因此在数据之外,数据库系统还维护着满足特定查找算法的数据结构。

这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

7. MySQL 的 B+Tree
目前大多数数据库系统及文件系统都采用 B-Tree 或其变种 B+Tree 作为索引结构。

B+ 树索引是 B+ 树在数据库中的一种实现,是最常见也是数据库中使用最为频繁的一种索引。B+ 树中的 B 代表平衡,而不是二叉。

因为 B+ 树是从最早的平衡二叉树演化而来的。B+ 树是由二叉查找树、平衡二叉树(AVLTree)和平衡多路查找树(B-Tree)逐步优化而来。

二叉查找树:左子树的键值小于根的键值,右子树的键值大于根的键值。

AVL 树:平衡二叉树(AVL 树)在符合二叉查找树的条件下,还满足任何节点的两个子树的高度最大差为 1。

平衡多路查找树(B-Tree):为磁盘等外存储设备设计的一种平衡查找树。

系统从磁盘读取数据到内存时是以磁盘块(block)为基本单位的,位于同一磁盘块中的数据会被一次性读取出来,而不是按需读取。

InnoDB 存储引擎使用页作为数据读取单位,页是其磁盘管理的最小单位,默认 page 大小是 16k。

系统的一个磁盘块的存储空间往往没有这么大,因此 InnoDB 每次申请磁盘空间时都会是若干地址连续磁盘块来达到页的大小 16KB。

InnDB 在把磁盘数据读入到磁盘时会以页为基本单位,在查询数据时如果一个页中的每条数据都能助于定位数据记录的位置,这将会减少磁盘 I/O 的次数,提高查询效率。

B-Tree 结构的数据可以让系统高效的找到数据所在的磁盘块。

为了描述 B-Tree,首先定义一条数据记录为一个二元组 [key, data],key 为记录的键值,对于不同数据记录,key 是互不相同的;data 为数据记录除 key 外的数据。

那么 B-Tree 是满足下列条件的数据结构:

  1. d 为大于 1 的一个正整数,称为 B-Tree 的度。
  2. h 为一个正整数,称为 B-Tree 的高度。
  3. 每个非叶子节点由 n-1 个 key 和 n 个指针组成,其中 d<=n<=2d。
  4. 每个叶子节点最少包含一个 key 和两个指针,最多包含 2d-1 个 key 和 2d 个指针,叶节点的指针均为 null 。
  5. 所有叶节点具有相同的深度,等于树高 h。
  6. key 和指针互相间隔,节点两端是指针。
  7. 一个节点中的 key 从左到右非递减排列。
  8. 所有节点组成树结构。
  9. 每个指针要么为 null,要么指向另外一个节点。
  10. 如果某个指针在节点 node 最左边且不为 null,则其指向节点的所有 key 小于 v(key1),其中 v(key1) 为
    node 的第一个 key 的值。
  11. 如果某个指针在节点 node 最右边且不为 null,则其指向节点的所有 key 大于 v(keym),其中 v(keym) 为
    node 的最后一个 key 的值。
  12. 如果某个指针在节点 node 的左右相邻 key 分别是 keyi 和 keyi+1 且不为 null,则其指向节点的所有 key
    小于 v(keyi+1) 且大于 v(keyi)。

B-Tree 中的每个节点根据实际情况可以包含大量的关键字信息和分支,例:

JAVA高级面试题——2019_第50张图片

每个节点占用一个盘块的磁盘空间,一个节点上有两个升序排序的关键字和三个指向子树根节点的指针,指针存储的是子节点所在磁盘块的地址。

两个关键词划分成的三个范围域对应三个指针指向的子树的数据的范围域。

以根节点为例,关键字为 17 和 35,P1 指针指向的子树的数据范围为小于 17,P2 指针指向的子树的数据范围为 17~35,P3 指针指向的子树的数据范围为大于 35。

模拟查找关键字 29 的过程:

  1. 根据根节点找到磁盘块 1,读入内存。【磁盘 I/O 操作第 1 次】
  2. 比较关键字 29 在区间(17,35),找到磁盘块 1 的指针 P2。
  3. 根据 P2 指针找到磁盘块 3,读入内存。【磁盘 I/O 操作第 2 次】
  4. 比较关键字 29 在区间(26,30),找到磁盘块 3 的指针 P2。
  5. 根据 P2 指针找到磁盘块 8,读入内存。【磁盘 I/O 操作第 3 次】
  6. 在磁盘块 8 中的关键字列表中找到关键字 29。

MySQL 的 InnoDB 存储引擎在设计时是将根节点常驻内存的,因此力求达到树的深度不超过 3,也就是说 I/O 不需要超过 3 次。

分析上面过程,发现需要 3 次磁盘 I/O 操作,和 3 次内存查找操作。由于内存中的关键字是一个有序表结构,可以利用二分法查找提高效率。

而 3 次磁盘 I/O 操作是影响整个 B-Tree 查找效率的决定因素。

B-Tree 相对于 AVLTree 缩减了节点个数,使每次磁盘 I/O 取到内存的数据都发挥了作用,从而提高了查询效率。

B+Tree 是在 B-Tree 基础上的一种优化,使其更适合实现外存储索引结构,InnoDB 存储引擎就是用 B+Tree 实现其索引结构。

在 B-Tree 中,每个节点中有 key,也有 data,而每一个页的存储空间是有限的,如果 data 数据较大时将会导致每个节点(即一个页)能存储的 key 的数量很小。

当存储的数据量很大时同样会导致 B-Tree 的深度较大,增大查询时的磁盘 I/O 次数,进而影响查询效率。

在 B+Tree 中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储 key 值信息,这样可以大大加大每个节点存储的 key 值数量,降低 B+Tree 的高度。

B+Tree 在 B-Tree 的基础上有两点变化:

  1. 数据是存在叶子节点中的;
  2. 数据节点之间是有指针指向的。

由于 B+Tree 的非叶子节点只存储键值信息,假设每个磁盘块能存储 4 个键值及指针信息,则变成 B+Tree 后其结构如下图所示:

JAVA高级面试题——2019_第51张图片

通常在 B+Tree 上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。

因此可以对 B+Tree 进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。

8. Myisam 中的 B+Tree
Myisam 引擎也是采用的 B+Tree 结构来作为索引结构。

由于 Myisam 中的索引和数据分别存放在不同的文件,所以在索引树中的叶子节点中存的数据是该索引对应的数据记录的地址,由于数据与索引不在一起,所以 Myisam 是非聚簇索引。

JAVA高级面试题——2019_第52张图片

9. InnoDB 中的 B+Tree
InnoDB 是以 ID 为索引的数据存储。

采用 InnoDB 引擎的数据存储文件有两个,一个定义文件,一个是数据文件。

InnoDB 通过 B+Tree 结构对 ID 建索引,然后在叶子节点中存储记录。

JAVA高级面试题——2019_第53张图片

若建索引的字段不是主键 ID,则对该字段建索引,然后在叶子节点中存储的是该记录的主键,然后通过主键索引找到对应的记录。

MySQL 的相关优化
1. MySQL 性能优化:组成、表的设计
开启查询缓存。避免某些 SQL 函数直接在 SQL 语句中使用,从而导致 Mysql 缓存失效。

避免画蛇添足。目的是什么就取什么,例如某个逻辑是只需要判断是否存在女性,若是查到了一条即可,勿要全部都查一遍,此时要善用 limit。

建合适的索引。所以要建在合适的地方,合适的对象上。经常操作 / 比较 / 判断的字段应该建索引。

字段大小合宜。字段的取值是有限而且是固定的,这种情况下可以用 enum,IP 字段可以用 unsigned int 来存储。

表的设计。垂直分割表,使得固定表与变长表分割,从而降低表的复杂度和字段的数目。

2. SQL 语句优化:避免全表扫描
建索引:一般在 where 及 order by 中涉及到的列上建索引,尽量不要对可以重复的字段建索引。

尽量避免在 where 中使用 !(<>)或 or,也不要进行 null 值判断。

尽量避免在 where 中对字段进行函数操作、表达式操作。

尽量避免使用 like- %,在此种情况下可以进行全文检索。

Mysql数据库隔离级别?

一、数据库事务隔离级别

数据库事务的隔离级别有4个,由低到高依次为Read uncommitted 、Read committed 、Repeatable read 、Serializable ,这四个级别可以逐个解决脏读 、不可重复读 、幻读 这几类问题。

JAVA高级面试题——2019_第54张图片

注意:我们讨论隔离级别的场景,主要是在多个事务并发 的情况下,因此,接下来的讲解都围绕事务并发。

Read uncommitted 读未提交
公司发工资了,领导把5000元打到singo的账号上,但是该事务并未提交,而singo正好去查看账户,发现工资已经到账,是5000元整,非常高 兴。可是不幸的是,领导发现发给singo的工资金额不对,是2000元,于是迅速回滚了事务,修改金额后,将事务提交,最后singo实际的工资只有 2000元,singo空欢喜一场。

JAVA高级面试题——2019_第55张图片

出现上述情况,即我们所说的脏读 ,两个并发的事务,“事务A:领导给singo发工资”、“事务B:singo查询工资账户”,事务B读取了事务A尚未提交的数据。

当隔离级别设置为Read uncommitted 时,就可能出现脏读,如何避免脏读,请看下一个隔离级别。

Read committed 读提交
singo拿着工资卡去消费,系统读取到卡里确实有2000元,而此时她的老婆也正好在网上转账,把singo工资卡的2000元转到另一账户,并在 singo之前提交了事务,当singo扣款时,系统检查到singo的工资卡已经没有钱,扣款失败,singo十分纳闷,明明卡里有钱,为 何…

出现上述情况,即我们所说的不可重复读 ,两个并发的事务,“事务A:singo消费”、“事务B:singo的老婆网上转账”,事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。

当隔离级别设置为Read committed 时,避免了脏读,但是可能会造成不可重复读。

大多数数据库的默认级别就是Read committed,比如Sql Server , Oracle。如何解决不可重复读这一问题,请看下一个隔离级别。

Repeatable read 重复读
当隔离级别设置为Repeatable read 时,可以避免不可重复读。当singo拿着工资卡去消费时,一旦系统开始读取工资卡信息(即事务开始),singo的老婆就不可能对该记录进行修改,也就是singo的老婆不能在此时转账。

虽然Repeatable read避免了不可重复读,但还有可能出现幻读 。

singo的老婆工作在银行部门,她时常通过银行内部系统查看singo的信用卡消费记录。有一天,她正在查询到singo当月信用卡的总消费金额 (select sum(amount) from transaction where month = 本月)为80元,而singo此时正好在外面胡吃海塞后在收银台买单,消费1000元,即新增了一条1000元的消费记录(insert transaction … ),并提交了事务,随后singo的老婆将singo当月信用卡消费的明细打印到A4纸上,却发现消费总额为1080元,singo的老婆很诧异,以为出 现了幻觉,幻读就这样产生了。

注:Mysql的默认隔离级别就是Repeatable read。

Serializable 序列化
Serializable 是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻像读。

二、脏读、幻读、不可重复读

1.脏读:
脏读就是指当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。

2.不可重复读:
是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。(即不能读到相同的数据内容)
例如,一个编辑人员两次读取同一文档,但在两次读取之间,作者重写了该文档。当编辑人员第二次读取文档时,文档已更改。原始读取不可重复。如果只有在作者全部完成编写后编辑人员才可以读取文档,则可以避免该问题。

3.幻读:
是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象
发生了幻觉一样。
例如,一个编辑人员更改作者提交的文档,但当生产部门将其更改内容合并到该文档的主复本时,发现作者已将未编辑的新材料添加到该文档中。如果在编辑人员和生产部门完成对原始文档的处理之前,任何人都不能将新材料添加到文档中,则可以避免该问题。

———————————————— 版权声明:本文为CSDN博主「jiesa」的原创文章,遵循 CC 4.0 BY-SA
版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/JIESA/article/details/51317164

Mysql如何实现ACID?

事务是MySQL等关系型数据库区别于NoSQL的重要方面,是保证数据一致性的重要手段。本文将首先介绍MySQL事务相关的基础概念,然后介绍事务的ACID特性,并分析其实现原理。

MySQL博大精深,文章疏漏之处在所难免,欢迎批评指正。

一、基础概念
事务(Transaction)是访问和更新数据库的程序执行单元;事务中可能包含一个或多个sql语句,这些语句要么都执行,要么都不执行。作为一个关系型数据库,MySQL支持事务,本文介绍基于MySQL5.6。

首先回顾一下MySQL事务的基础知识。

1. 逻辑架构和存储引擎

JAVA高级面试题——2019_第56张图片

如上图所示,MySQL服务器逻辑架构从上往下可以分为三层:

(1)第一层:处理客户端连接、授权认证等。

(2)第二层:服务器层,负责查询语句的解析、优化、缓存以及内置函数的实现、存储过程等。

(3)第三层:存储引擎,负责MySQL中数据的存储和提取。MySQL中服务器层不管理事务,事务是由存储引擎实现的。MySQL支持事务的存储引擎有InnoDB、NDB Cluster等,其中InnoDB的使用最为广泛;其他存储引擎不支持事务,如MyIsam、Memory等。

如无特殊说明,后文中描述的内容都是基于InnoDB。

2. 提交和回滚
典型的MySQL事务是如下操作的:

start transaction;
……  #一条或多条sql语句
commit;

其中start transaction标识事务开始,commit提交事务,将执行结果写入到数据库。如果sql语句执行出现问题,会调用rollback,回滚所有已经执行成功的sql语句。当然,也可以在事务中直接使用rollback语句进行回滚。

自动提交

MySQL中默认采用的是自动提交(autocommit)模式,如下所示:

在这里插入图片描述
在自动提交模式下,如果没有start transaction显式地开始一个事务,那么每个sql语句都会被当做一个事务执行提交操作。

通过如下方式,可以关闭autocommit;需要注意的是,autocommit参数是针对连接的,在一个连接中修改了参数,不会对其他连接产生影响。

JAVA高级面试题——2019_第57张图片
如果关闭了autocommit,则所有的sql语句都在一个事务中,直到执行了commit或rollback,该事务结束,同时开始了另外一个事务。

特殊操作

在MySQL中,存在一些特殊的命令,如果在事务中执行了这些命令,会马上强制执行commit提交事务;如DDL语句(create table/drop table/alter/table)、lock tables语句等等。

不过,常用的select、insert、update和delete命令,都不会强制提交事务。

3. ACID特性
ACID是衡量事务的四个特性:

  • 原子性(Atomicity,或称不可分割性)
  • 一致性(Consistency)
  • 隔离性(Isolation)
  • 持久性(Durability)

按照严格的标准,只有同时满足ACID特性才是事务;但是在各大数据库厂商的实现中,真正满足ACID的事务少之又少。例如MySQL的NDB Cluster事务不满足持久性和隔离性;InnoDB默认事务隔离级别是可重复读,不满足隔离性;Oracle默认的事务隔离级别为READ COMMITTED,不满足隔离性……因此与其说ACID是事务必须满足的条件,不如说它们是衡量事务的四个维度。

下面将详细介绍ACID特性及其实现原理;为了便于理解,介绍的顺序不是严格按照A-C-I-D。

二、原子性

  1. 定义
    原子性是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做;如果事务中一个sql语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态。

2. 实现原理:undo log
在说明原子性原理之前,首先介绍一下MySQL的事务日志。MySQL的日志有很多种,如二进制日志、错误日志、查询日志、慢查询日志等,此外InnoDB存储引擎还提供了两种事务日志:redo log(重做日志)和undo log(回滚日志)。其中redo log用于保证事务持久性;undo log则是事务原子性和隔离性实现的基础。

下面说回undo log。实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。InnoDB实现回滚,靠的是undo log:当事务对数据库进行修改时,InnoDB会生成对应的undo log;如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

undo log属于逻辑日志,它记录的是sql执行相关的信息。当发生回滚时,InnoDB会根据undo log的内容做与之前相反的工作:对于每个insert,回滚时会执行delete;对于每个delete,回滚时会执行insert;对于每个update,回滚时会执行一个相反的update,把数据改回去。

以update操作为例:当事务执行update时,其生成的undo log中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到update之前的状态。

三、持久性

  1. 定义
    持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

2. 实现原理:redo log
redo log和undo log都属于InnoDB的事务日志。下面先聊一下redo log存在的背景。

InnoDB作为MySQL的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool),Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)。

Buffer Pool的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

于是,redo log被引入来解决这个问题:当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。

既然redo log也需要在事务提交时将日志写入磁盘,为什么它比直接将Buffer Pool中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:

(1)刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作,属于顺序IO。

(2)刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。

  1. redo log与binlog
    我们知道,在MySQL中还存在binlog(二进制日志)也可以记录写操作并用于数据的恢复,但二者是有着根本的不同的:

(1)作用不同:redo log是用于crash recovery的,保证MySQL宕机也不会影响持久性;binlog是用于point-in-time recovery的,保证服务器可以基于时间点恢复数据,此外binlog还用于主从复制。

(2)层次不同:redo log是InnoDB存储引擎实现的,而binlog是MySQL的服务器层(可以参考文章前面对MySQL逻辑架构的介绍)实现的,同时支持InnoDB和其他存储引擎。

(3)内容不同:redo log是物理日志,内容基于磁盘的Page;binlog的内容是二进制的,根据binlog_format参数的不同,可能基于sql语句、基于数据本身或者二者的混合。

(4)写入时机不同:binlog在事务提交时写入;redo log的写入时机相对多元:

前面曾提到:当事务提交时会调用fsync对redo log进行刷盘;这是默认情况下的策略,修改innodb_flush_log_at_trx_commit参数可以改变该策略,但事务的持久性将无法保证。
除了事务提交时,还有其他刷盘时机:如master thread每秒刷盘一次redo log等,这样的好处是不一定要等到commit时刷盘,commit速度大大加快。

四、隔离性
1. 定义
与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。隔离性是指,事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。严格的隔离性,对应了事务隔离级别中的Serializable (可串行化),但实际应用中出于性能方面的考虑很少会使用可串行化。

隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们仅考虑最简单的读操作和写操作(暂时不考虑带锁读等特殊操作),那么隔离性的探讨,主要可以分为两个方面:

  • (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
  • (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性

2. 锁机制
首先来看两个事务的写操作之间的相互影响。隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。

锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。

行锁与表锁

按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差;行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源(获得锁、检查锁、释放锁等都需要消耗资源),因此在锁定数据较多情况下使用表锁可以节省大量资源。MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。

如何查看锁信息

有多种方法可以查看InnoDB中锁的情况,例如:

select * from information_schema.innodb_locks; #锁的概况
show engine innodb status; #InnoDB整体状态,其中包括锁的情况

下面来看一个例子:

#在事务A中执行:
start transaction;
update account SET balance = 1000 where id = 1;
#在事务B中执行:
start transaction;
update account SET balance = 2000 where id = 1;

此时查看锁的情况:

JAVA高级面试题——2019_第58张图片
show engine innodb status查看锁相关的部分:

在这里插入图片描述
通过上述命令可以查看事务24052和24053占用锁的情况;其中lock_type为RECORD,代表锁为行锁(记录锁);lock_mode为X,代表排它锁(写锁)。

除了排它锁(写锁)之外,MySQL中还有共享锁(读锁)的概念。由于本文重点是MySQL事务的实现原理,因此对锁的介绍到此为止,后续会专门写文章分析MySQL中不同锁的区别、使用场景等,欢迎关注。

介绍完写操作之间的相互影响,下面讨论写操作对读操作的影响。

3. 脏读、不可重复读和幻读
首先来看并发情况下,读操作可能存在的三类问题:

(1)脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。举例如下(以账户余额表为例):

JAVA高级面试题——2019_第59张图片(2)不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。举例如下:

JAVA高级面试题——2019_第60张图片(3)幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。举例如下:

JAVA高级面试题——2019_第61张图片
4. 事务隔离级别
SQL标准中定义了四种隔离级别,并规定了每种隔离级别下上述几个问题是否存在。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下:

JAVA高级面试题——2019_第62张图片
在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。因此在大多数数据库系统中,默认的隔离级别是读已提交(如Oracle)或可重复读(后文简称RR)。

可以通过如下两个命令分别查看全局隔离级别和本次会话的隔离级别:

JAVA高级面试题——2019_第63张图片

JAVA高级面试题——2019_第64张图片
InnoDB默认的隔离级别是RR,后文会重点介绍RR。需要注意的是,在SQL标准中,RR是无法避免幻读问题的,但是InnoDB实现的RR避免了幻读问题。

5. MVCC
RR解决脏读、不可重复读、幻读等问题,使用的是MVCC:MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。下面的例子很好的体现了MVCC的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)——在T5时刻,事务A和事务C可以读取到不同版本的数据。

JAVA高级面试题——2019_第65张图片MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要是依靠数据的隐藏列(也可以称之为标记位)和undo log。其中数据的隐藏列包括了该行数据的版本号、删除时间、指向undo log的指针等等;当读取数据时,MySQL可以通过隐藏列判断是否需要回滚并找到回滚需要的undo log,从而实现MVCC;隐藏列的详细格式不再展开。

下面结合前文提到的几个问题分别说明。

(1)脏读

JAVA高级面试题——2019_第66张图片
当事务A在T3时间节点读取zhangsan的余额时,会发现数据已被其他事务修改,且状态为未提交。此时事务A读取最新数据后,根据数据的undo log执行回滚操作,得到事务B修改前的数据,从而避免了脏读。

(2)不可重复读

JAVA高级面试题——2019_第67张图片当事务A在T2节点第一次读取数据时,会记录该数据的版本号(数据的版本号是以row为单位记录的),假设版本号为1;当事务B提交时,该行记录的版本号增加,假设版本号为2;当事务A在T5再一次读取数据时,发现数据的版本号(2)大于第一次读取时记录的版本号(1),因此会根据undo log执行回滚操作,得到版本号为1时的数据,从而实现了可重复读。

(3)幻读

InnoDB实现的RR通过next-key lock机制避免了幻读现象。

next-key lock是行锁的一种,实现相当于record lock(记录锁) + gap lock(间隙锁);其特点是不仅会锁住记录本身(record lock的功能),还会锁定一个范围(gap lock的功能)。当然,这里我们讨论的是不加锁读:此时的next-key lock并不是真的加锁,只是为读取的数据增加了标记(标记内容包括数据的版本号等);准确起见姑且称之为类next-key lock机制。还是以前面的例子来说明:

JAVA高级面试题——2019_第68张图片当事务A在T2节点第一次读取0

6. 总结
概括来说,InnoDB实现的RR,通过锁机制、数据的隐藏列、undo log和类next-key lock,实现了一定程度的隔离性,可以满足大多数场景的需要。不过需要说明的是,RR虽然避免了幻读问题,但是毕竟不是Serializable,不能保证完全的隔离,下面是一个例子,大家可以自己验证一下。

JAVA高级面试题——2019_第69张图片
五、一致性

1. 基本概念
一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)。

2. 实现
可以说,一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。

实现一致性的措施包括:

保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致

六、总结

下面总结一下ACID特性及其实现原理:

  • 原子性:语句要么全执行,要么全不执行,是事务最核心的特性,事务本身就是以原子性来定义的;实现主要基于undo log
  • 持久性:保证事务提交后不会因为宕机等原因导致数据丢失;实现主要基于redo log
  • 隔离性:保证事务执行尽可能不受其他事务影响;InnoDB默认的隔离级别是RR,RR的实现主要基于锁机制、数据的隐藏列、undo
    log和类next-key lock机制
  • 一致性:事务追求的最终目标,一致性的实现既需要数据库层面的保障,也需要应用层面的保障

Mysql千万级优化?

很多人第一反应是各种切分;我给的顺序是:
第一优化你的sql和索引
第二加缓存,memcached,redis
第三以上都做了后,还是慢,就做主从复制或主主复制,读写分离,可以在应用层做,效率高,也可以用三方工具,第三方工具推荐360的atlas,其它的要么效率不高,要么没人维护;
第四如果以上都做了还是慢,不要想着去做切分,mysql自带分区表,先试试这个,对你的应用是透明的,无需更改代码,但是sql语句是需要针对分区表做优化的,sql条件中要带上分区条件的列,从而使查询定位到少量的分区上,否则就会扫描全部分区,另外分区表还有一些坑,在这里就不多说了;
第五如果以上都做了,那就先做垂直拆分,其实就是根据你模块的耦合度,将一个大的系统分为多个小的系统,也就是分布式系统;
第六才是水平切分,针对数据量大的表,这一步最麻烦,最能考验技术水平,要选择一个合理的sharding key,为了有好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key,将数据定位到限定的表上去查,而不是扫描全部的表;mysql数据库一般都是按照这个步骤去演化的,成本也是由低到高;有人也许要说第一步优化sql和索引这还用说吗?的确,大家都知道,但是很多情况下,这一步做的并不到位,甚至有的只做了根据sql去建索引,根本没对sql优化(中枪了没?),除了最简单的增删改查外,想实现一个查询,可以写出很多种查询语句,不同的语句,根据你选择的引擎、表中数据的分布情况、索引情况、数据库优化策略、查询中的锁策略等因素,最终查询的效率相差很大;优化要从整体去考虑,有时你优化一条语句后,其它查询反而效率被降低了,所以要取一个平衡点;即使精通mysql的话,除了纯技术面优化,还要根据业务面去优化sql语句,这样才能达到最优效果;你敢说你的sql和索引已经是最优了吗?再说一下不同引擎的优化,myisam读的效果好,写的效率差,这和它数据存储格式,索引的指针和锁的策略有关的,它的数据是顺序存储的(innodb数据存储方式是聚簇索引),他的索引btree上的节点是一个指向数据物理位置的指针,所以查找起来很快,(innodb索引节点存的则是数据的主键,所以需要根据主键二次查找);myisam锁是表锁,只有读读之间是并发的,写写之间和读写之间(读和插入之间是可以并发的,去设置concurrent_insert参数,定期执行表优化操作,更新操作就没有办法了)是串行的,所以写起来慢,并且默认的写优先级比读优先级高,高到写操作来了后,可以马上插入到读操作前面去,如果批量写,会导致读请求饿死,所以要设置读写优先级或设置多少写操作后执行读操作的策略;myisam不要使用查询时间太长的sql,如果策略使用不当,也会导致写饿死,所以尽量去拆分查询效率低的sql,innodb一般都是行锁,这个一般指的是sql用到索引的时候,行锁是加在索引上的,不是加在数据记录上的,如果sql没有用到索引,仍然会锁定表,mysql的读写之间是可以并发的,普通的select是不需要锁的,当查询的记录遇到锁时,用的是一致性的非锁定快照读,也就是根据数据库隔离级别策略,会去读被锁定行的快照,其它更新或加锁读语句用的是当前读,读取原始行;因为普通读与写不冲突,所以innodb不会出现读写饿死的情况,又因为在使用索引的时候用的是行锁,锁的粒度小,竞争相同锁的情况就少,就增加了并发处理,所以并发读写的效率还是很优秀的,问题在于索引查询后的根据主键的二次查找导致效率低;ps:很奇怪,为什innodb的索引叶子节点存的是主键而不是像mysism一样存数据的物理地址指针吗?如果存的是物理地址指针不就不需要二次查找了吗,这也是我开始的疑惑,根据mysism和innodb数据存储方式的差异去想,你就会明白了,我就不费口舌了!所以innodb为了避免二次查找可以使用索引覆盖技术,无法使用索引覆盖的,再延伸一下就是基于索引覆盖实现延迟关联;不知道什么是索引覆盖的,建议你无论如何都要弄清楚它是怎么回事!尽你所能去优化你的sql吧!说它成本低,却又是一项费时费力的活,需要在技术与业务都熟悉的情况下,用心去优化才能做到最优,优化后的效果也是立竿见影的!

单表优化

除非单表数据未来会一直不断上涨,否则不要一开始就考虑拆分,拆分会带来逻辑、部署、运维的各种复杂度,一般以整型值为主的表在千万级以下,字符串为主的表在五百万以下是没有太大问题的。而事实上很多时候MySQL单表的性能依然有不少优化空间,甚至能正常支撑千万级以上的数据量:

字段

  • 尽量使用TINYINT、SMALLINT、MEDIUM_INT作为整数类型而非INT,如果非负则加上UNSIGNED
  • VARCHAR的长度只分配真正需要的空间
  • 使用枚举或整数代替字符串类型
  • 尽量使用TIMESTAMP而非DATETIME,
  • 单表不要有太多字段,建议在20以内
  • 避免使用NULL字段,很难查询优化且占用额外索引空间
  • 用整型来存IP

索引

  • 索引并不是越多越好,要根据查询有针对性的创建,考虑在WHERE和ORDER
    BY命令上涉及的列建立索引,可根据EXPLAIN来查看是否用了索引还是全表扫描
  • 应尽量避免在WHERE子句中对字段进行NULL值判断,否则将导致引擎放弃使用索引而进行全表扫描
  • 值分布很稀少的字段不适合建索引,例如"性别"这种只有两三个值的字段
  • 字符字段只建前缀索引
  • 字符字段最好不要做主键
  • 不用外键,由程序保证约束
  • 尽量不用UNIQUE,由程序保证约束
  • 使用多列索引时主意顺序和查询条件保持一致,同时删除不必要的单列索引

查询SQL

  • 可通过开启慢查询日志来找出较慢的SQL
  • 不做列运算:SELECT id WHERE age + 1 =
    10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边
  • sql语句尽可能简单:一条sql只能在一个cpu运算;大语句拆小语句,减少锁时间;一条大sql可以堵死整个库
  • 不用SELECT *
  • OR改写成IN:OR的效率是n级别,IN的效率是log(n)级别,in的个数建议控制在200以内
  • 不用函数和触发器,在应用程序实现
  • 避免%xxx式查询
  • 少用JOIN
  • 使用同类型进行比较,比如用’123’和’123’比,123和123比
  • 尽量避免在WHERE子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描
  • 对于连续数值,使用BETWEEN不用IN:SELECT id FROM t WHERE num BETWEEN 1 AND 5
  • 列表数据不要拿全表,要使用LIMIT来分页,每页数量也不要太大

引擎
目前广泛使用的是MyISAM和InnoDB两种引擎:

MyISAM
MyISAM引擎是MySQL 5.1及之前版本的默认引擎,它的特点是:

  • 不支持行锁,读取时对需要读到的所有表加锁,写入时则对表加排它锁
  • 不支持事务
  • 不支持外键
  • 不支持崩溃后的安全恢复
  • 在表有读取查询的同时,支持往表中插入新纪录
  • 支持BLOB和TEXT的前500个字符索引,支持全文索引
  • 支持延迟更新索引,极大提升写入性能
  • 对于不会进行修改的表,支持压缩表,极大减少磁盘空间占用

InnoDB
InnoDB在MySQL 5.5后成为默认索引,它的特点是:

  • 支持行锁,采用MVCC来支持高并发
  • 支持事务
  • 支持外键
  • 支持崩溃后的安全恢复
  • 不支持全文索引

总体来讲,MyISAM适合SELECT密集型的表,而InnoDB适合INSERT和UPDATE密集型的表

系统调优参数
可以使用下面几个工具来做基准测试:

  • sysbench:一个模块化,跨平台以及多线程的性能测试工具
  • iibench-mysql:基于 Java 的 MySQL/Percona/MariaDB 索引进行插入性能测试工具
  • tpcc-mysql:Percona开发的TPC-C测试工具

具体的调优参数内容较多,具体可参考官方文档,这里介绍一些比较重要的参数:

  • back_log:back_log值指出在MySQL暂时停止回答新请求之前的短时间内多少个请求可以被存在堆栈中。也就是说,如果MySql的连接数据达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源。可以从默认的50升至500
  • wait_timeout:数据库连接闲置时间,闲置连接会占用内存资源。可以从默认的8小时减到半小时
  • max_user_connection: 最大连接数,默认为0无上限,最好设一个合理上限
  • thread_concurrency:并发线程数,设为CPU核数的两倍
  • skip_name_resolve:禁止对外部连接进行DNS解析,消除DNS解析时间,但需要所有远程主机用IP访问
  • key_buffer_size:索引块的缓存大小,增加会提升索引处理速度,对MyISAM表性能影响最大。对于内存4G左右,可设为256M或384M,通过查询show
    status like ‘key_read%’,保证key_reads / key_read_requests在0.1%以下最好
  • innodb_buffer_pool_size:缓存数据块和索引块,对InnoDB表性能影响最大。通过查询show status like
    ‘Innodb_buffer_pool_read%’,保证 (Innodb_buffer_pool_read_requests –
    Innodb_buffer_pool_reads) / Innodb_buffer_pool_read_requests越高越好
  • innodb_additional_mem_pool_size:InnoDB存储引擎用来存放数据字典信息以及一些内部数据结构的内存空间大小,当数据库对象非常多的时候,适当调整该参数的大小以确保所有数据都能存放在内存中提高访问效率,当过小的时候,MySQL会记录Warning信息到数据库的错误日志中,这时就需要该调整这个参数大小
  • innodb_log_buffer_size:InnoDB存储引擎的事务日志所使用的缓冲区,一般来说不建议超过32MB
  • query_cache_size:缓存MySQL中的ResultSet,也就是一条SQL语句执行的结果集,所以仅仅只能针对select语句。当某个表的数据有任何任何变化,都会导致所有引用了该表的select语句在Query
    Cache中的缓存数据失效。所以,当我们的数据变化非常频繁的情况下,使用Query
    Cache可能会得不偿失。根据命中率(Qcache_hits/(Qcache_hits+Qcache_inserts)*100))进行调整,一般不建议太大,256MB可能已经差不多了,大型的配置型静态数据可适当调大.
    可以通过命令show status like 'Qcache_%'查看目前系统Query catch使用大小
  • read_buffer_size:MySql读入缓冲区大小。对表进行顺序扫描的请求将分配一个读入缓冲区,MySql会为它分配一段内存缓冲区。如果对表的顺序扫描请求非常频繁,可以通过增加该变量值以及内存缓冲区大小提高其性能
  • sort_buffer_size:MySql执行排序使用的缓冲大小。如果想要增加ORDER
    BY的速度,首先看是否可以让MySQL使用索引而不是额外的排序阶段。如果不能,可以尝试增加sort_buffer_size变量的大小
  • read_rnd_buffer_size:MySql的随机读缓冲区大小。当按任意顺序读取行时(例如,按照排序顺序),将分配一个随机读缓存区。进行排序查询时,MySql会首先扫描一遍该缓冲,以避免磁盘搜索,提高查询速度,如果需要排序大量数据,可适当调高该值。但MySql会为每个客户连接发放该缓冲空间,所以应尽量适当设置该值,以避免内存开销过大。
  • record_buffer:每个进行一个顺序扫描的线程为其扫描的每张表分配这个大小的一个缓冲区。如果你做很多顺序扫描,可能想要增加该值
  • thread_cache_size:保存当前没有与连接关联但是准备为后面新的连接服务的线程,可以快速响应连接的线程请求而无需创建新的
  • table_cache:类似于thread_cache_size,但用来缓存表文件,对InnoDB效果不大,主要用于MyISAM

升级硬件
Scale up,这个不多说了,根据MySQL是CPU密集型还是I/O密集型,通过提升CPU和内存、使用SSD,都能显著提升MySQL性能

读写分离
也是目前常用的优化,从库读主库写,一般不要采用双主或多主引入很多复杂性,尽量采用文中的其他方案来提高性能。同时目前很多拆分的解决方案同时也兼顾考虑了读写分离

缓存
缓存可以发生在这些层次:

  • MySQL内部:在系统调优参数介绍了相关设置
  • 数据访问层:比如MyBatis针对SQL语句做缓存,而Hibernate可以精确到单个记录,这里缓存的对象主要是持久化对象Persistence
    Object
  • 应用服务层:这里可以通过编程手段对缓存做到更精准的控制和更多的实现策略,这里缓存的对象是数据传输对象Data Transfer
    Object
  • Web层:针对web页面做缓存
  • 浏览器客户端:用户端的缓存

可以根据实际情况在一个层次或多个层次结合加入缓存。这里重点介绍下服务层的缓存实现,目前主要有两种方式:

  • 直写式(Write Through):在数据写入数据库后,同时更新缓存,维持数据库与缓存的一致性。这也是当前大多数应用缓存框架如Spring
    Cache的工作方式。这种实现非常简单,同步好,但效率一般。
  • 回写式(Write Back):当有数据要写入数据库时,只会更新缓存,然后异步批量的将缓存数据同步到数据库上。这种实现比较复杂,需要较多的应用逻辑,同时可能会产生数据库与缓存的不同步,但效率非常高。

表分区
MySQL在5.1版引入的分区是一种简单的水平拆分,用户需要在建表的时候加上分区参数,对应用是透明的无需修改代码

对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成,实现分区的代码实际上是通过对一组底层表的对象封装,但对SQL层来说是一个完全封装底层的黑盒子。MySQL实现分区的方式也意味着索引也是按照分区的子表定义,没有全局索引

JAVA高级面试题——2019_第70张图片

用户的SQL语句是需要针对分区表做优化,SQL条件中要带上分区条件的列,从而使查询定位到少量的分区上,否则就会扫描全部分区,可以通过EXPLAIN PARTITIONS来查看某条SQL语句会落在那些分区上,从而进行SQL优化,如下图5条记录落在两个分区上:

mysql> explain partitions select count(1) from user_partition where id in (1,2,3,4,5);
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table          | partitions | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | user_partition | p1,p4      | range | PRIMARY       | PRIMARY | 8       | NULL |    5 | Using where; Using index |
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.00 sec)

分区的好处是:

  • 可以让单表存储更多的数据
  • 分区表的数据更容易维护,可以通过清楚整个分区批量删除大量数据,也可以增加新的分区来支持新插入的数据。另外,还可以对一个独立分区进行优化、检查、修复等操作
  • 部分查询能够从查询条件确定只落在少数分区上,速度会很快
  • 分区表的数据还可以分布在不同的物理设备上,从而搞笑利用多个硬件设备
  • 可以使用分区表赖避免某些特殊瓶颈,例如InnoDB单个索引的互斥访问、ext3文件系统的inode锁竞争
  • 可以备份和恢复单个分区

分区的限制和缺点

  • 一个表最多只能有1024个分区
  • 如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来
  • 分区表无法使用外键约束
  • NULL值会使分区过滤无效

所有分区必须使用相同的存储引擎

分区的类型:

  • RANGE分区:基于属于一个给定连续区间的列值,把多行分配给分区
  • LIST分区:类似于按RANGE分区,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择
  • HASH分区:基于用户定义的表达式的返回值来进行选择的分区,该表达式使用将要插入到表中的这些行的列值进行计算。这个函数可以包含MySQL中有效的、产生非负整数值的任何表达式
  • KEY分区:类似于按HASH分区,区别在于KEY分区只支持计算一列或多列,且MySQL服务器提供其自身的哈希函数。必须有一列或多列包含整数值

分区适合的场景有:

最适合的场景数据的时间序列性比较强,则可以按时间来分区,如下所示:

CREATE TABLE members (
    firstname VARCHAR(25) NOT NULL,
    lastname VARCHAR(25) NOT NULL,
    username VARCHAR(16) NOT NULL,
    email VARCHAR(35),
    joined DATE NOT NULL
)
PARTITION BY RANGE( YEAR(joined) ) (
    PARTITION p0 VALUES LESS THAN (1960),
    PARTITION p1 VALUES LESS THAN (1970),
    PARTITION p2 VALUES LESS THAN (1980),
    PARTITION p3 VALUES LESS THAN (1990),
    PARTITION p4 VALUES LESS THAN MAXVALUE
);

查询时加上时间范围条件效率会非常高,同时对于不需要的历史数据能很容的批量删除。

  • 如果数据有明显的热点,而且除了这部分数据,其他数据很少被访问到,那么可以将热点数据单独放在一个分区,让这个分区的数据能够有机会都缓存在内存中,查询时只访问一个很小的分区表,能够有效使用索引和缓存

另外MySQL有一种早期的简单的分区实现 - 合并表(merge table),限制较多且缺乏优化,不建议使用,应该用新的分区机制来替代

垂直拆分
垂直分库是根据数据库里面的数据表的相关性进行拆分,比如:一个数据库里面既存在用户数据,又存在订单数据,那么垂直拆分可以把用户数据放到用户库、把订单数据放到订单库。垂直分表是对数据表进行垂直拆分的一种方式,常见的是把一个多字段的大表按常用字段和非常用字段进行拆分,每个表里面的数据记录数一般情况下是相同的,只是字段不一样,使用主键关联

比如原始的用户表是:

JAVA高级面试题——2019_第71张图片

垂直拆分后是:

JAVA高级面试题——2019_第72张图片

垂直拆分的优点是:

  • 可以使得行数据变小,一个数据块(Block)就能存放更多的数据,在查询时就会减少I/O次数(每次查询时读取的Block 就少)
  • 可以达到最大化利用Cache的目的,具体在垂直拆分的时候可以将不常变的字段放一起,将经常改变的放一起
  • 数据维护简单

缺点是:

  • 主键出现冗余,需要管理冗余列
  • 会引起表连接JOIN操作(增加CPU开销)可以通过在业务服务器上进行join来减少数据库压力
  • 依然存在单表数据量过大的问题(需要水平拆分)
  • 事务处理复杂

水平拆分
概述
水平拆分是通过某种策略将数据分片来存储,分库内分表和分库两部分,每片数据会分散到不同的MySQL表或库,达到分布式的效果,能够支持非常大的数据量。前面的表分区本质上也是一种特殊的库内分表

库内分表,仅仅是单纯的解决了单一表数据过大的问题,由于没有把表的数据分布到不同的机器上,因此对于减轻MySQL服务器的压力来说,并没有太大的作用,大家还是竞争同一个物理机上的IO、CPU、网络,这个就要通过分库来解决

前面垂直拆分的用户表如果进行水平拆分,结果是:

JAVA高级面试题——2019_第73张图片

实际情况中往往会是垂直拆分和水平拆分的结合,即将Users_A_M和Users_N_Z再拆成Users和UserExtras,这样一共四张表

水平拆分的优点是:

  • 不存在单库大数据和高并发的性能瓶颈
  • 应用端改造较少
  • 提高了系统的稳定性和负载能力

缺点是:

  • 分片事务一致性难以解决
  • 跨节点Join性能差,逻辑复杂
  • 数据多次扩展难度跟维护量极大

分片原则

  • 能不分就不分,参考单表优化
  • 分片数量尽量少,分片尽量均匀分布在多个数据结点上,因为一个查询SQL跨分片越多,则总体性能越差,虽然要好于所有数据在一个分片的结果,只在必要的时候进行扩容,增加分片数量
  • 分片规则需要慎重选择做好提前规划,分片规则的选择,需要考虑数据的增长模式,数据的访问模式,分片关联性问题,以及分片扩容问题,最近的分片策略为范围分片,枚举分片,一致性Hash分片,这几种分片都有利于扩容
  • 尽量不要在一个事务中的SQL跨越多个分片,分布式事务一直是个不好处理的问题
  • 查询条件尽量优化,尽量避免Select *
    的方式,大量数据结果集下,会消耗大量带宽和CPU资源,查询尽量避免返回大量结果集,并且尽量为频繁使用的查询语句建立索引。
  • 通过数据冗余和表分区赖降低跨库Join的可能

这里特别强调一下分片规则的选择问题,如果某个表的数据有明显的时间特征,比如订单、交易记录等,则他们通常比较合适用时间范围分片,因为具有时效性的数据,我们往往关注其近期的数据,查询条件中往往带有时间字段进行过滤,比较好的方案是,当前活跃的数据,采用跨度比较短的时间段进行分片,而历史性的数据,则采用比较长的跨度存储。

总体上来说,分片的选择是取决于最频繁的查询SQL的条件,因为不带任何Where语句的查询SQL,会遍历所有的分片,性能相对最差,因此这种SQL越多,对系统的影响越大,所以我们要尽量避免这种SQL的产生。

解决方案
由于水平拆分牵涉的逻辑比较复杂,当前也有了不少比较成熟的解决方案。这些方案分为两大类:客户端架构和代理架构。

客户端架构
通过修改数据访问层,如JDBC、Data Source、MyBatis,通过配置来管理多个数据源,直连数据库,并在模块内完成数据的分片整合,一般以Jar包的方式呈现

这是一个客户端架构的例子:

JAVA高级面试题——2019_第74张图片

可以看到分片的实现是和应用服务器在一起的,通过修改Spring JDBC层来实现

客户端架构的优点是:

  • 应用直连数据库,降低外围系统依赖所带来的宕机风险
  • 集成成本低,无需额外运维的组件

缺点是:

  • 限于只能在数据库访问层上做文章,扩展性一般,对于比较复杂的系统可能会力不从心
  • 将分片逻辑的压力放在应用服务器上,造成额外风险

代理架构
通过独立的中间件来统一管理所有数据源和数据分片整合,后端数据库集群对前端应用程序透明,需要独立部署和运维代理组件

这是一个代理架构的例子:

JAVA高级面试题——2019_第75张图片

代理组件为了分流和防止单点,一般以集群形式存在,同时可能需要Zookeeper之类的服务组件来管理

代理架构的优点是:

  • 能够处理非常复杂的需求,不受数据库访问层原来实现的限制,扩展性强
  • 对于应用服务器透明且没有增加任何额外负载

缺点是:

  • 需部署和运维独立的代理中间件,成本高
  • 应用需经过代理来连接数据库,网络上多了一跳,性能有损失且有额外风险

各方案比较

  1. 最好按大公司->社区->小公司->个人这样的出品方顺序来选择
  2. 选择口碑较好的,比如github星数、使用者数量质量和使用者反馈
  3. 开源的优先,往往项目有特殊需求可能需要改动源代码

按照上述思路,推荐以下选择:

客户端架构:ShardingJDBC
代理架构:MyCat或者Atlas

兼容MySQL且可水平扩展的数据库
目前也有一些开源数据库兼容MySQL协议,如:

  • TiDB
  • Cubrid

但其工业品质和MySQL尚有差距,且需要较大的运维投入,如果想将原始的MySQL迁移到可水平扩展的新数据库中,可以考虑一些云数据库:

  • 阿里云PetaData
  • 阿里云OceanBase
  • 腾讯云DCDB

NoSQL
在MySQL上做Sharding是一种戴着镣铐的跳舞,事实上很多大表本身对MySQL这种RDBMS的需求并不大,并不要求ACID,可以考虑将这些表迁移到NoSQL,彻底解决水平扩展问题,例如:

  • 日志类、监控类、统计类数据
  • 非结构化或弱结构化数据
  • 对事务要求不强,且无太多关联操作的数据

索引

参考:https://blog.csdn.net/longzhutengyue/article/details/95357467

框架

Spring

SpringIOC

谈谈对Spring IOC的理解
  学习过Spring框架的人一定都会听过Spring的IoC(控制反转) 、DI(依赖注入)这两个概念,对于初学Spring的人来说,总觉得IoC 、DI这两个概念是模糊不清的,是很难理解的,今天和大家分享网上的一些技术大牛们对Spring框架的IOC的理解以及谈谈我对Spring Ioc的理解。

一、分享Iteye的开涛对Ioc的精彩讲解
  首先要分享的是Iteye的开涛这位技术牛人对Spring框架的IOC的理解,写得非常通俗易懂,以下内容全部来自原文,原文地址:http://jinnianshilongnian.iteye.com/blog/1413846

1.1、IoC是什么
  Ioc—Inversion of Control,即“控制反转”,不是什么技术,而是一种设计思想。在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。如何理解好Ioc呢?理解好Ioc的关键是要明确“谁控制谁,控制什么,为何是反转(有反转就应该有正转了),哪些方面反转了”,那我们来深入分析一下:

●谁控制谁,控制什么:传统Java SE程序设计,我们直接在对象内部通过new进行创建对象,是程序主动去创建依赖对象;而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制对 象的创建;谁控制谁?当然是IoC 容器控制了对象;控制什么?那就是主要控制了外部资源获取(不只是对象包括比如文件等)。

●为何是反转,哪些方面反转了:有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;为何是反转?因为由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,所以是反转;哪些方面反转了?依赖对象的获取被反转了。

用图例说明一下,传统程序设计如图2-1,都是主动去创建相关对象然后再组合起来:

JAVA高级面试题——2019_第76张图片
图1-1 传统应用程序示意图

当有了IoC/DI的容器后,在客户端类中不再主动去创建这些对象了,如图2-2所示:

JAVA高级面试题——2019_第77张图片

图1-2有IoC/DI容器后程序结构示意图

1.2、IoC能做什么
  IoC 不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是 松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

其实IoC对编程带来的最大改变不是从代码上,而是从思想上,发生了“主从换位”的变化。应用程序原本是老大,要获取什么资源都是主动出击,但是在IoC/DI思想中,应用程序就变成被动的了,被动的等待IoC容器来创建并注入它所需要的资源了。

IoC很好的体现了面向对象设计法则之一—— 好莱坞法则:“别找我们,我们找你”;即由IoC容器帮对象找相应的依赖对象并注入,而不是由对象主动去找。

1.3、IoC和DI
  DI—Dependency Injection,即“依赖注入”:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。

理解DI的关键是:“谁依赖谁,为什么需要依赖,谁注入谁,注入了什么”,那我们来深入分析一下:

●谁依赖于谁:当然是应用程序依赖于IoC容器;

●为什么需要依赖:应用程序需要IoC容器来提供对象需要的外部资源;

●谁注入谁:很明显是IoC容器注入应用程序某个对象,应用程序依赖的对象;

●注入了什么:就是注入某个对象所需要的外部资源(包括对象、资源、常量数据)。

IoC和DI由什么关系呢?其实它们是同一个概念的不同角度描述,由于控制反转概念比较含糊(可能只是理解为容器控制对象这一个层面,很难让人想到谁来维护对象关系),所以2004年大师级人物Martin Fowler又给出了一个新的名字:“依赖注入”,相对IoC 而言,“依赖注入”明确描述了“被注入对象依赖IoC容器配置依赖对象”。

看过很多对Spring的Ioc理解的文章,好多人对Ioc和DI的解释都晦涩难懂,反正就是一种说不清,道不明的感觉,读完之后依然是一头雾水,感觉就是开涛这位技术牛人写得特别通俗易懂,他清楚地解释了IoC(控制反转) 和DI(依赖注入)中的每一个字,读完之后给人一种豁然开朗的感觉。我相信对于初学Spring框架的人对Ioc的理解应该是有很大帮助的。

二、分享Bromon的blog上对IoC与DI浅显易懂的讲解
2.1、IoC(控制反转)
  首先想说说IoC(Inversion of Control,控制反转)。这是spring的核心,贯穿始终。所谓IoC,对于spring框架来说,就是由spring来负责控制对象的生命周期和对象间的关系。这是什么意思呢,举个简单的例子,我们是如何找女朋友的?常见的情况是,我们到处去看哪里有长得漂亮身材又好的mm,然后打听她们的兴趣爱好、qq号、电话号、ip号、iq号………,想办法认识她们,投其所好送其所要,然后嘿嘿……这个过程是复杂深奥的,我们必须自己设计和面对每个环节。传统的程序开发也是如此,在一个对象中,如果要使用另外的对象,就必须得到它(自己new一个,或者从JNDI中查询一个),使用完之后还要将对象销毁(比如Connection等),对象始终会和其他的接口或类藕合起来。

那么IoC是如何做的呢?有点像通过婚介找女朋友,在我和女朋友之间引入了一个第三者:婚姻介绍所。婚介管理了很多男男女女的资料,我可以向婚介提出一个列表,告诉它我想找个什么样的女朋友,比如长得像李嘉欣,身材像林熙雷,唱歌像周杰伦,速度像卡洛斯,技术像齐达内之类的,然后婚介就会按照我们的要求,提供一个mm,我们只需要去和她谈恋爱、结婚就行了。简单明了,如果婚介给我们的人选不符合要求,我们就会抛出异常。整个过程不再由我自己控制,而是有婚介这样一个类似容器的机构来控制。Spring所倡导的开发方式就是如此,所有的类都会在spring容器中登记,告诉spring你是个什么东西,你需要什么东西,然后spring会在系统运行到适当的时候,把你要的东西主动给你,同时也把你交给其他需要你的东西。所有的类的创建、销毁都由 spring来控制,也就是说控制对象生存周期的不再是引用它的对象,而是spring。对于某个具体的对象而言,以前是它控制其他对象,现在是所有对象都被spring控制,所以这叫控制反转。

2.2、DI(依赖注入)
  IoC的一个重点是在系统运行中,动态的向某个对象提供它所需要的其他对象。这一点是通过DI(Dependency Injection,依赖注入)来实现的。比如对象A需要操作数据库,以前我们总是要在A中自己编写代码来获得一个Connection对象,有了 spring我们就只需要告诉spring,A中需要一个Connection,至于这个Connection怎么构造,何时构造,A不需要知道。在系统运行时,spring会在适当的时候制造一个Connection,然后像打针一样,注射到A当中,这样就完成了对各个对象之间关系的控制。A需要依赖 Connection才能正常运行,而这个Connection是由spring注入到A中的,依赖注入的名字就这么来的。那么DI是如何实现的呢? Java 1.3之后一个重要特征是反射(reflection),它允许程序在运行的时候动态的生成对象、执行对象的方法、改变对象的属性,spring就是通过反射来实现注入的。

理解了IoC和DI的概念后,一切都将变得简单明了,剩下的工作只是在spring的框架中堆积木而已。

三、我对IoC(控制反转)和DI(依赖注入)的理解
  在平时的java应用开发中,我们要实现某一个功能或者说是完成某个业务逻辑时至少需要两个或以上的对象来协作完成,在没有使用Spring的时候,每个对象在需要使用他的合作对象时,自己均要使用像new object() 这样的语法来将合作对象创建出来,这个合作对象是由自己主动创建出来的,创建合作对象的主动权在自己手上,自己需要哪个合作对象,就主动去创建,创建合作对象的主动权和创建时机是由自己把控的,而这样就会使得对象间的耦合度高了,A对象需要使用合作对象B来共同完成一件事,A要使用B,那么A就对B产生了依赖,也就是A和B之间存在一种耦合关系,并且是紧密耦合在一起,而使用了Spring之后就不一样了,创建合作对象B的工作是由Spring来做的,Spring创建好B对象,然后存储到一个容器里面,当A对象需要使用B对象时,Spring就从存放对象的那个容器里面取出A要使用的那个B对象,然后交给A对象使用,至于Spring是如何创建那个对象,以及什么时候创建好对象的,A对象不需要关心这些细节问题(你是什么时候生的,怎么生出来的我可不关心,能帮我干活就行),A得到Spring给我们的对象之后,两个人一起协作完成要完成的工作即可。

所以控制反转IoC(Inversion of Control)是说创建对象的控制权进行转移,以前创建对象的主动权和创建时机是由自己把控的,而现在这种权力转移到第三方,比如转移交给了IoC容器,它就是一个专门用来创建对象的工厂,你要什么对象,它就给你什么对象,有了 IoC容器,依赖关系就变了,原先的依赖关系就没了,它们都依赖IoC容器了,通过IoC容器来建立它们之间的关系。

这是我对Spring的IoC(控制反转)的理解。DI(依赖注入)其实就是IOC的另外一种说法,DI是由Martin Fowler 在2004年初的一篇论文中首次提出的。他总结:控制的什么被反转了?就是:获得依赖对象的方式反转了。

四、小结
  对于Spring Ioc这个核心概念,我相信每一个学习Spring的人都会有自己的理解。这种概念上的理解没有绝对的标准答案,仁者见仁智者见智。如果有理解不到位或者理解错的地方,欢迎广大园友指正!

Spring容器高层视图

Spring 启动时读取应用程序提供的Bean配置信息,并在Spring容器中生成一份相应的Bean配置注册表,然后根据这张注册表实例化Bean,装配好Bean之间的依赖关系,为上层应用提供准备就绪的运行环境。

JAVA高级面试题——2019_第78张图片
Bean缓存池:HashMap实现

IOC容器介绍

Spring 通过一个配置文件描述 Bean 及 Bean 之间的依赖关系,利用 Java 语言的反射功能实例化 Bean 并建立 Bean 之间的依赖关系。 Spring 的 IoC 容器在完成这些底层工作的基础上,还提供了 Bean 实例缓存、生命周期管理、 Bean 实例代理、事件发布、资源装载等高级服务。

BeanFactory 是 Spring 框架的基础设施,面向 Spring 本身;

ApplicationContext 面向使用 Spring 框架的开发者,几乎所有的应用场合我们都直接使用 ApplicationContext 而非底层的 BeanFactory。

BeanFactory

BeanFactory体系架构:
JAVA高级面试题——2019_第79张图片

BeanDefinitionRegistry: Spring 配置文件中每一个节点元素在 Spring 容器里都通过一个 BeanDefinition 对象表示,它描述了 Bean 的配置信息。而 BeanDefinitionRegistry 接口提供了向容器手工注册 BeanDefinition 对象的方法。

BeanFactory 接口位于类结构树的顶端 ,它最主要的方法就是 getBean(String beanName),该方法从容器中返回特定名称的 Bean,BeanFactory 的功能通过其他的接口得到不断扩展:

ListableBeanFactory:该接口定义了访问容器中 Bean 基本信息的若干方法,如查看Bean 的个数、获取某一类型 Bean 的配置名、查看容器中是否包括某一 Bean 等方法;

HierarchicalBeanFactory:父子级联 IoC 容器的接口,子容器可以通过接口方法访问父容器; 通过 HierarchicalBeanFactory 接口, Spring 的 IoC 容器可以建立父子层级关联的容器体系,子容器可以访问父容器中的 Bean,但父容器不能访问子容器的 Bean。Spring 使用父子容器实现了很多功能,比如在 Spring MVC 中,展现层 Bean 位于一个子容器中,而业务层和持久层的 Bean 位于父容器中。这样,展现层 Bean 就可以引用业务层和持久层的 Bean,而业务层和持久层的 Bean 则看不到展现层的 Bean。

ConfigurableBeanFactory:是一个重要的接口,增强了 IoC 容器的可定制性,它定义了设置类装载器、属性编辑器、容器初始化后置处理器等方法;

AutowireCapableBeanFactory:定义了将容器中的 Bean 按某种规则(如按名字匹配、按类型匹配等)进行自动装配的方法;

SingletonBeanRegistry:定义了允许在运行期间向容器注册单实例 Bean 的方法;

例子:

使用 Spring 配置文件为 Car 提供配置信息:beans.xml:

JAVA高级面试题——2019_第80张图片
通过 BeanFactory 装载配置文件,启动 Spring IoC 容器:

JAVA高级面试题——2019_第81张图片
XmlBeanFactory 通过 Resource 装载 Spring 配置信息并启动 IoC 容器,然后就可以通过 BeanFactory#getBean(beanName)方法从 IoC 容器中获取 Bean 了。通过 BeanFactory 启动IoC 容器时,并不会初始化配置文件中定义的 Bean,初始化动作发生在第一个调用时。

对于单实例( singleton)的 Bean 来说,BeanFactory会缓存 Bean 实例,所以第二次使用 getBean() 获取 Bean 时将直接从 IoC 容器的缓存中获取 Bean 实例。Spring 在 DefaultSingletonBeanRegistry 类中提供了一个用于缓存单实例 Bean 的缓存器,它是一个用HashMap 实现的缓存器,单实例的 Bean 以 beanName 为键保存在这个HashMap 中。

值得一提的是,在初始化 BeanFactory 时,必须为其提供一种日志框架,比如使用Log4J, 即在类路径下提供 Log4J 配置文件,这样启动 Spring 容器才不会报错。

ApplicationContext

ApplicationContext 由 BeanFactory 派生而来,提供了更多面向实际应用的功能。

在BeanFactory 中,很多功能需要以编程的方式实现,而在 ApplicationContext 中则可以通过配置的方式实现。
JAVA高级面试题——2019_第82张图片

ApplicationContext 继承了 HierarchicalBeanFactory 和 ListableBeanFactory 接口,在此基础上,还通过多个其他的接口扩展了 BeanFactory 的功能:

ClassPathXmlApplicationContext:默认从类路径加载配置文件

FileSystemXmlApplicationContext:默认从文件系统中装载配置文件

ApplicationEventPublisher:让容器拥有发布应用上下文事件的功能,包括容器启动事件、关闭事件等。实现了 ApplicationListener 事件监听接口的 Bean 可以接收到容器事件 , 并对事件进行响应处理 。 在 ApplicationContext 抽象实现类AbstractApplicationContext 中,我们可以发现存在一个 ApplicationEventMulticaster,它负责保存所有监听器,以便在容器产生上下文事件时通知这些事件监听者。

MessageSource:为应用提供 i18n 国际化消息访问的功能;

ResourcePatternResolver : 所 有 ApplicationContext 实现类都实现了类似于PathMatchingResourcePatternResolver 的功能,可以通过带前缀的 Ant 风格的资源文件路径装载 Spring 的配置文件。

LifeCycle:该接口是 Spring 2.0 加入的,该接口提供了 start()和 stop()两个方法,主要用于控制异步处理过程。在具体使用时,该接口同时被 ApplicationContext 实现及具体 Bean 实现, ApplicationContext 会将 start/stop 的信息传递给容器中所有实现了该接口的 Bean,以达到管理和控制 JMX、任务调度等目的。

ConfigurableApplicationContext 扩展于 ApplicationContext,它新增加了两个主要的方法: refresh()和 close(),让 ApplicationContext 具有启动、刷新和关闭应用上下文的能力。在应用上下文关闭的情况下调用 refresh()即可启动应用上下文,在已经启动的状态下,调用 refresh()则清除缓存并重新装载配置信息,而调用close()则可关闭应用上下文。这些接口方法为容器的控制管理带来了便利,但作为开发者,我们并不需要过多关心这些方法。

使用:

如果配置文件放置在类路径下,用户可以优先使用 ClassPathXmlApplicationContext 实现类:

如果配置文件放置在文件系统的路径下,则可以优先考虑使用 FileSystemXmlApplicationContext 实现类:

Spring 3.0 支持基于类注解的配置方式,主要功能来自于 Spring 的一个名为 JavaConfig 子项目,目前 JavaConfig已经升级为 Spring核心框架的一部分。

ApplicationContext 在初始化应用上下文时就实例化所有单实例的 Bean。

WebApplicationContext

WebApplication体系架构:

JAVA高级面试题——2019_第83张图片
WebApplicationContext 是专门为 Web 应用准备的,它允许从相对于 Web 根目录的路径中装载配置文件完成初始化工作。从WebApplicationContext 中可以获得 ServletContext 的引用,整个 Web 应用上下文对象将作为属性放置到 ServletContext 中,以便 Web 应用环境可以访问 Spring 应用上下文。 WebApplicationContext 定义了一个常量ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,在上下文启动时, WebApplicationContext 实例即以此为键放置在 ServletContext 的属性列表中,因此我们可以直接通过以下语句从 Web 容器中获取WebApplicationContext:

在这里插入图片描述
Spring 和 Web 应用的上下文融合:

JAVA高级面试题——2019_第84张图片
WebApplicationContext 的初始化方式:WebApplicationContext 需要 ServletContext 实例,它必须在拥有 Web 容器的前提下才能完成启动的工作。可以在 web.xml 中配置自启动的 Servlet 或定义 Web 容器监听器( ServletContextListener),借助这两者中的任何一个就可以完成启动 Spring Web 应用上下文的工作。Spring 分别提供了用于启动 WebApplicationContext 的 Servlet 和 Web 容器监听器:

org.springframework.web.context.ContextLoaderServlet;

org.springframework.web.context.ContextLoaderListener

由于 WebApplicationContext 需要使用日志功能,比如日志框架使用Log4J,用户可以将 Log4J 的配置文件放置到类路径 WEB-INF/classes 下,这时 Log4J 引擎即可顺利启动。如果 Log4J 配置文件放置在其他位置,用户还必须在 web.xml 指定 Log4J 配置文件位置。

Bean的生命周期

1.当调用者通过 getBean(beanName)向容器请求某一个 Bean 时,如果容器注册了org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor 接口,在实例化 Bean 之前,将调用接口的 postProcessBeforeInstantiation()方法;

2.根据配置情况调用 Bean 构造函数或工厂方法实例化 Bean;

3.如果容器注册了 InstantiationAwareBeanPostProcessor 接口,在实例化 Bean 之后,调用该接口的 postProcessAfterInstantiation()方法,可在这里对已经实例化的对象进行一些“梳妆打扮”;

4.如果 Bean 配置了属性信息,容器在这一步着手将配置值设置到 Bean 对应的属性中,不过在设置每个属性之前将先调用InstantiationAwareBeanPostProcessor 接口的postProcessPropertyValues()方法;

5.调用 Bean 的属性设置方法设置属性值;

6.如果 Bean 实现了 org.springframework.beans.factory.BeanNameAware 接口,将调用setBeanName()接口方法,将配置文件中该 Bean 对应的名称设置到 Bean 中;

7.如果 Bean 实现了 org.springframework.beans.factory.BeanFactoryAware 接口,将调用 setBeanFactory()接口方法,将 BeanFactory 容器实例设置到 Bean 中;

8.如果 BeanFactory 装配了 org.springframework.beans.factory.config.BeanPostProcessor后处理器,将调用 BeanPostProcessor 的 Object postProcessBeforeInitialization(Object bean, String beanName)接口方法对 Bean 进行加工操作。其中入参 bean 是当前正在处理的 Bean,而 beanName 是当前 Bean 的配置名,返回的对象为加工处理后的 Bean。用户可以使用该方法对某些 Bean 进行特殊的处理,甚至改变 Bean 的行为, BeanPostProcessor 在 Spring 框架中占有重要的地位,为容器提供对 Bean 进行后续加工处理的切入点, Spring 容器所提供的各种“神奇功能”(如 AOP,动态代理等)都通过 BeanPostProcessor 实施;

9.如果 Bean 实现了 InitializingBean 的接口,将调用接口的 afterPropertiesSet()方法;

10.如果在通过 init-method 属性定义了初始化方法,将执行这个方法;

11.BeanPostProcessor 后处理器定义了两个方法:其一是 postProcessBeforeInitialization() 在第 8 步调用;其二是 Object postProcessAfterInitialization(Object bean, String beanName)方法,这个方法在此时调用,容器再次获得对 Bean 进行加工处理的机会;

12.如果在中指定 Bean 的作用范围为 scope=“prototype”,将 Bean 返回给调用者,调用者负责 Bean 后续生命的管理, Spring 不再管理这个 Bean 的生命周期。如果作用范围设置为 scope=“singleton”,则将 Bean 放入到 Spring IoC 容器的缓存池中,并将 Bean引用返回给调用者, Spring 继续对这些 Bean 进行后续的生命管理;

13.对于 scope=“singleton”的 Bean,当容器关闭时,将触发 Spring 对 Bean 的后续生命周期的管理工作,首先如果 Bean 实现了 DisposableBean 接口,则将调用接口的afterPropertiesSet()方法,可以在此编写释放资源、记录日志等操作;

14.对于 scope=“singleton”的 Bean,如果通过的 destroy-method 属性指定了 Bean 的销毁方法, Spring 将执行 Bean 的这个方法,完成 Bean 资源的释放等操作。

可以将这些方法大致划分为三类:

Bean 自身的方法:如调用 Bean 构造函数实例化 Bean,调用 Setter 设置 Bean 的属性值以及通过的 init-method 和 destroy-method 所指定的方法;

Bean 级生命周期接口方法:如 BeanNameAware、 BeanFactoryAware、 InitializingBean 和 DisposableBean,这些接口方法由 Bean 类直接实现;

容器级生命周期接口方法:在上图中带“★” 的步骤是由 InstantiationAwareBean PostProcessor 和BeanPostProcessor 这两个接口实现,一般称它们的实现类为“ 后处理器” 。 后处理器接口一般不由 Bean 本身实现,它们独立于 Bean,实现类以容器附加装置的形式注册到 Spring 容器中并通过接口反射为 Spring 容器预先识别。当Spring 容器创建任何 Bean 的时候,这些后处理器都会发生作用,所以这些后处理器的影响是全局性的。当然,用户可以通过合理地编写后处理器,让其仅对感兴趣Bean 进行加工处理

ApplicationContext 和 BeanFactory 另一个最大的不同之处在于:ApplicationContext会利用 Java 反射机制自动识别出配置文件中定义的 BeanPostProcessor、 InstantiationAwareBeanPostProcessor 和 BeanFactoryPostProcessor,并自动将它们注册到应用上下文中;而后者需要在代码中通过手工调用 addBeanPostProcessor()方法进行注册。这也是为什么在应用开发时,我们普遍使用 ApplicationContext 而很少使用 BeanFactory 的原因之一

IOC容器工作机制

容器启动过程

web环境下Spring容器、SpringMVC容器启动过程:

首先,对于一个web应用,其部署在web容器中,web容器提供其一个全局的上下文环境,这个上下文就是ServletContext,其为后面的spring IoC容器提供宿主环境;

其次,在web.xml中会提供有contextLoaderListener(或ContextLoaderServlet)。在web容器启动时,会触发容器初始化事件,此时contextLoaderListener会监听到这个事件,其contextInitialized方法会被调用,在这个方法中,spring会初始化一个启动上下文,这个上下文被称为根上下文,即WebApplicationContext,这是一个接口类,确切的说,其实际的实现类是XmlWebApplicationContext。这个就是spring的IoC容器,其对应的Bean定义的配置由web.xml中的context-param标签指定。在这个IoC容器初始化完毕后,spring容器以WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE为属性Key,将其存储到ServletContext中,便于获取;

再次,contextLoaderListener监听器初始化完毕后,开始初始化web.xml中配置的Servlet,这个servlet可以配置多个,以最常见的DispatcherServlet为例(Spring MVC),这个servlet实际上是一个标准的前端控制器,用以转发、匹配、处理每个servlet请求。DispatcherServlet上下文在初始化的时候会建立自己的IoC上下文容器,用以持有spring mvc相关的bean,这个servlet自己持有的上下文默认实现类也是XmlWebApplicationContext。在建立DispatcherServlet自己的IoC上下文时,会利用WebApplicationContext.ROOTWEBAPPLICATIONCONTEXTATTRIBUTE先从ServletContext中获取之前的根上下文(即WebApplicationContext)作为自己上下文的parent上下文(即第2步中初始化的XmlWebApplicationContext作为自己的父容器)。有了这个parent上下文之后,再初始化自己持有的上下文(这个DispatcherServlet初始化自己上下文的工作在其initStrategies方法中可以看到,大概的工作就是初始化处理器映射、视图解析等)。初始化完毕后,spring以与servlet的名字相关(此处不是简单的以servlet名为Key,而是通过一些转换)的属性为属性Key,也将其存到ServletContext中,以便后续使用。这样每个servlet就持有自己的上下文,即拥有自己独立的bean空间,同时各个servlet共享相同的bean,即根上下文定义的那些bean。

Bean加载过程

Spring的高明之处在于,它使用众多接口描绘出了所有装置的蓝图,构建好Spring的骨架,继而通过继承体系层层推演,不断丰富,最终让Spring成为有血有肉的完整的框架。所以查看Spring框架的源码时,有两条清晰可见的脉络:

1)接口层描述了容器的重要组件及组件间的协作关系;

2)继承体系逐步实现组件的各项功能。

接口层清晰地勾勒出Spring框架的高层功能,框架脉络呼之欲出。有了接口层抽象的描述后,不但Spring自己可以提供具体的实现,任何第三方组织也可以提供不同实现, 可以说Spring完善的接口层使框架的扩展性得到了很好的保证。纵向继承体系的逐步扩展,分步骤地实现框架的功能,这种实现方案保证了框架功能不会堆积在某些类的身上,造成过重的代码逻辑负载,框架的复杂度被完美地分解开了。

Spring组件按其所承担的角色可以划分为两类:

1)物料组件:Resource、BeanDefinition、PropertyEditor以及最终的Bean等,它们是加工流程中被加工、被消费的组件,就像流水线上被加工的物料;

BeanDefinition:Spring通过BeanDefinition将配置文件中的配置信息转换为容器的内部表示,并将这些BeanDefinition注册到BeanDefinitionRegistry中。Spring容器的后续操作直接从BeanDefinitionRegistry中读取配置信息。

2)加工设备组件:ResourceLoader、BeanDefinitionReader、BeanFactoryPostProcessor、InstantiationStrategy以及BeanWrapper等组件像是流水线上不同环节的加工设备,对物料组件进行加工处理。

InstantiationStrategy:负责实例化Bean操作,相当于Java语言中new的功能,并不会参与Bean属性的配置工作。属性填充工作留待BeanWrapper完成

BeanWrapper:继承了PropertyAccessor和PropertyEditorRegistry接口,BeanWrapperImpl内部封装了两类组件:(1)被封装的目标Bean(2)一套用于设置Bean属性的属性编辑器;具有三重身份:(1)Bean包裹器(2)属性访问器 (3)属性编辑器注册表。PropertyAccessor:定义了各种访问Bean属性的方法。PropertyEditorRegistry:属性编辑器的注册表

该图描述了Spring容器从加载配置文件到创建出一个完整Bean的作业流程:

JAVA高级面试题——2019_第85张图片
1、ResourceLoader从存储介质中加载Spring配置信息,并使用Resource表示这个配置文件的资源;

2、BeanDefinitionReader读取Resource所指向的配置文件资源,然后解析配置文件。配置文件中每一个解析成一个BeanDefinition对象,并保存到BeanDefinitionRegistry中;

3、容器扫描BeanDefinitionRegistry中的BeanDefinition,使用Java的反射机制自动识别出Bean工厂后处理后器(实现BeanFactoryPostProcessor接口)的Bean,然后调用这些Bean工厂后处理器对BeanDefinitionRegistry中的BeanDefinition进行加工处理。主要完成以下两项工作:

1)对使用到占位符的元素标签进行解析,得到最终的配置值,这意味对一些半成品式的BeanDefinition对象进行加工处理并得到成品的BeanDefinition对象;

2)对BeanDefinitionRegistry中的BeanDefinition进行扫描,通过Java反射机制找出所有属性编辑器的Bean(实现java.beans.PropertyEditor接口的Bean),并自动将它们注册到Spring容器的属性编辑器注册表中(PropertyEditorRegistry);

4.Spring容器从BeanDefinitionRegistry中取出加工后的BeanDefinition,并调用InstantiationStrategy着手进行Bean实例化的工作;

5.在实例化Bean时,Spring容器使用BeanWrapper对Bean进行封装,BeanWrapper提供了很多以Java反射机制操作Bean的方法,它将结合该Bean的BeanDefinition以及容器中属性编辑器,完成Bean属性的设置工作;

6.利用容器中注册的Bean后处理器(实现BeanPostProcessor接口的Bean)对已经完成属性设置工作的Bean进行后续加工,直接装配出一个准备就绪的Bean。

总结

Spring IOC容器主要有继承体系底层的BeanFactory、高层的ApplicationContext和WebApplicationContext

Bean有自己的生命周期

容器启动原理:Spring应用的IOC容器通过tomcat的Servlet或Listener监听启动加载;Spring MVC的容器由DispatchServlet作为入口加载;Spring容器是Spring MVC容器的父容器

容器加载Bean原理:

BeanDefinitionReader读取Resource所指向的配置文件资源,然后解析配置文件。配置文件中每一个解析成一个BeanDefinition对象,并保存到BeanDefinitionRegistry中;

容器扫描BeanDefinitionRegistry中的BeanDefinition;调用InstantiationStrategy进行Bean实例化的工作;使用BeanWrapper完成Bean属性的设置工作;

单例Bean缓存池:Spring 在 DefaultSingletonBeanRegistry 类中提供了一个用于缓存单实例 Bean 的缓存器,它是一个用 HashMap 实现的缓存器,单实例的 Bean 以 beanName 为键保存在这个HashMap 中。

SpringAOP

Spring中AOP源码深入解析

有关AOP相关概念以及Spring AOP相关概念和Spring AOP的使用不再重复。关于AOP在Spring中的地位,不用说相信我们都知道,也都会用,但是对于更深入的东西,还未接触过,这里就对Spring AOP的相关源码进行说明一下,看看到底Spring中AOP是怎么实现的。

有关AOP的概念和Spring AOP相关配置,请参考其他两篇文章:AOP概念,原理,应用介绍 和 Spring中AOP的配置从1.0到5.0的演进

另外,本文使用的源码是Spring1.1.1版本的,之所以使用这么老的版本,是觉得相对来说简单一些,并且无关的东西更少,这样更容易去理解。对于后续版本新增功能可以在此基础上进行对比,理解的效果会更好。

示例程序
首先我们还是先使用一个实例来看一下怎么使用,再从实例中一步一步跟进到源码中。

先定义业务接口和实现:

LoginService:

package me.cxis.spring.aop;

/**
 * Created by cheng.xi on 2017-03-29 12:02.
 */
public interface LoginService {
    String login(String userName);
}

LoginServiceImpl:

package me.cxis.spring.aop;

/**
 * Created by cheng.xi on 2017-03-29 10:36.
 */
public class LoginServiceImpl implements LoginService {

    public String login(String userName){
        System.out.println("正在登录");
        return "success";
    }
}

接着是三个通知类:

//这里只是在登录方法调用之前打印一句话
public class LogBeforeLogin implements MethodBeforeAdvice {
    public void before(Method method, Object[] objects, Object o) throws Throwable {
        System.out.println("有人要登录了。。。");
    }
}

package me.cxis.spring.aop.proxyfactory;
import org.springframework.aop.AfterReturningAdvice;
import org.springframework.aop.MethodBeforeAdvice;
import java.lang.reflect.Method;

/**
 * Created by cheng.xi on 2017-03-29 10:56.
 */
public class LogAfterLogin implements AfterReturningAdvice {

    public void afterReturning(Object o, Method method, Object[] objects, Object o1) throws Throwable {
        System.out.println("有人已经登录了。。。");
    }
}

package me.cxis.spring.aop.proxyfactory;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

/**
 * Created by cheng.xi on 2017-03-30 23:36.
 */
public class LogAroundLogin implements MethodInterceptor {
    public Object invoke(MethodInvocation invocation) throws Throwable {
        System.out.println("有人要登录。。。");
        Object result = invocation.proceed();
        System.out.println("登录完了");
        return result;
    }
}

测试方法:

package me.cxis.spring.aop.proxyfactory;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

/**
 * Created by cheng.xi on 2017-03-29 10:34.
 */
public class Main {


    public static void main(String[] args) {
        ProxyFactory proxyFactory = new ProxyFactory();//创建代理工厂
        proxyFactory.setTarget(new LoginServiceImpl());//设置目标对象
        proxyFactory.addAdvice(new LogBeforeLogin());//前置增强
        proxyFactory.addAdvice(new LogAfterLogin());//后置增强
        //proxyFactory.addAdvice(new LogAroundLogin());//环绕增强

        LoginService loginService = (LoginService) proxyFactory.getProxy();//从代理工厂中获取代理
        loginService.login("x");
    }
}

关于实例中要说明的:我们看到在使用的时候,直接获取的是一个代理,不是要使用的实现类,这也很好懂,之前文章都说过AOP其实就是代理模式,在编译期或者运行期,给我们原来的代码增加一些功能,变成一个代理。当我们调用的时候,实际就是调用的代理类。

源码解析
对于源码的解析,我们这里使用的是代码的方式,没有选择xml配置文件的方式。关于xml配置的方式,后面再讲解。

创建AOP代理
首先我们要明白,Spring中实现AOP,就是生成一个代理,然后在使用的时候调用代理。

创建代理工厂
代码中首先创建一个代理工厂实例ProxyFactory proxyFactory = new ProxyFactory();代理工厂的作用就是使用编程的方式创建AOP代理。ProxyFactory继承自AdvisedSupport,AdvicedSupport是AOP代理的配置管理器。

设置目标对象
然后是设置要代理的目标对象proxyFactory.setTarget(new LoginServiceImpl());,看下setTarget方法:

public void setTarget(Object target) {
	//先根据给定的目标实现类,创建一个单例的TargetSource
    //然后设置TargetSource
    setTargetSource(new SingletonTargetSource(target));
}

TargetSource
TargetSource用来获取当前的Target,也就是TargetSource中会保存着我们的的实现类。

public interface TargetSource {

	//返回目标类的类型
	Class getTargetClass();
	
	//查看TargetSource是否是static的
    //静态的TargetSource每次都返回同一个Target
	boolean isStatic();
	
	//获取目标类的实例
	Object getTarget() throws Exception;
	
	//释放目标类
	void releaseTarget(Object target) throws Exception;

}

SingletonTargetSource
TargetSource的默认实现,是一个单例的TargetSource,isStatic方法直接返回true。

public final class SingletonTargetSource implements TargetSource, Serializable {

	//用来保存目标类	
	private final Object target;
	//构造方法
	public SingletonTargetSource(Object target) {
		this.target = target;
	}
	//直接返回目标类的类型
	public Class getTargetClass() {
		return target.getClass();
	}
	//返回目标类
	public Object getTarget() {
		return this.target;
	}
	//释放目标类,这里啥也没做
	public void releaseTarget(Object o) {
		// Nothing to do
	}
	//直接返回true
	public boolean isStatic() {
		return true;
	}

	//equals方法
	public boolean equals(Object other) {
    	//相等,返回true
		if (this == other) {
			return true;
		}
        //不是SingletonTargetSource类型的返回false
		if (!(other instanceof SingletonTargetSource)) {
			return false;
		}
		SingletonTargetSource otherTargetSource = (SingletonTargetSource) other;
        //判断目标类是否相等
		return ObjectUtils.nullSafeEquals(this.target, otherTargetSource.target);
	}
	
	//toString方法
	public String toString() {
		return "SingletonTargetSource: target=(" + target + ")";
	}
}

上面是有关TargetSource和SingletonTargetSource的说明,接着往下一步就是设置目标类setTargetSource方法,在AdvisedSupport类中:

public void setTargetSource(TargetSource targetSource) {
    if (isActive() && getOptimize()) {
        throw new AopConfigException("Can't change target with an optimized CGLIB proxy: it has its own target");
    }
    //么有做什么处理,只是将我们构建的TargetSource缓存起来
    this.targetSource = targetSource;
}

添加通知
上面设置了要代理的目标类之后,接着是添加通知,也就是添加增强类,proxyFactory.addAdvice()方法是添加增强类的方法。我们在例子中是这么使用的:

proxyFactory.addAdvice(new LogBeforeLogin());//前置增强
proxyFactory.addAdvice(new LogAfterLogin());//后置增强
//proxyFactory.addAdvice(new LogAroundLogin());//环绕增强

addAdvice方法的参数是一个Advice类型的类,也就是通知或者叫增强,可以去我们的增强类中查看,我们都继承了各种Advice,比如MethodBeforeAdvice,AfterReturningAdvice,MethodInterceptor,这里先讲一下有关通知Advice的代码,然后再继续说明addAdvice方法。

Advice接口
Advice不属于Spring,是AOP联盟定义的接口。Advice接口并没有定义任何方法,是一个空的接口,用来做标记,实现了此接口的的类是一个通知类。Advice有几个子接口:

  • BeforeAdvice,前置增强,意思是在我们的目标类之前调用的增强。这个接口也没有定义任何方法。
  • AfterReturningAdvice,方法正常返回前的增强,该增强可以看到方法的返回值,但是不能更改返回值,该接口有一个方法afterReturning
  • ThrowsAdvice,抛出异常时候的增强,也是一个标志接口,没有定义任何方法。
  • Interceptor,拦截器,也没有定义任何方法,表示一个通用的拦截器。不属于Spring,是AOP联盟定义的接口
  • DynamicIntroductionAdvice,动态引介增强,有一个方法implementsInterface。

MethodBeforeAdvice
MethodBeforeAdvice接口,是BeforeAdvice的子接口,表示在方法前调用的增强,方法前置增强不能阻止方法的调用,但是能抛异常来使目标方法不继续执行。

public interface MethodBeforeAdvice extends BeforeAdvice {
	
	//在给定的方法调用前,调用该方法
    //参数method是被代理的方法
    //参数args是被代理方法的参数
    //参数target是方法调用的目标,可能为null
	void before(Method m, Object[] args, Object target) throws Throwable;
}

MethodInterceptor
MethodInterceptor不属于Spring,是AOP联盟定义的接口,是Interceptor的子接口,我们通常叫做环绕增强。

public interface MethodInterceptor extends Interceptor {
	
    //在目标方法调用前后做一些事情
    //返回的是invocation.proceed()方法的返回值
    Object invoke(MethodInvocation invocation) throws Throwable;
}

参数MethodInvocation是一个方法调用的连接点,接下来先看看MethodInvocation相关的代码。

Joinpoint连接点
JointPoint接口,是一个通用的运行时连接点,运行时连接点是在一个静态连接点发生的事件。

public interface Joinpoint {

   //开始调用拦截器链中的下一个拦截器
   Object proceed() throws Throwable;

   //
   Object getThis();

   //
   AccessibleObject getStaticPart();   

}

Invocation接口
Invocation接口是Joinpoint的子接口,表示程序的调用,一个Invocation就是一个连接点,可以被拦截器拦截。

public interface Invocation extends Joinpoint {
   
   //获取参数
   Object[] getArguments();

}

MethodInvocation接口
MethodInvocation接口是Invocation的子接口,用来描述一个方法的调用。

public interface MethodInvocation extends Invocation
{

    //获取被调用的方法
    Method getMethod();

}

另外还有一个ConstructorInvocation接口,也是Invocation的子接口,描述的是构造器的调用。

上面介绍完了Advice的相关定义,接着看往代理工厂中添加增强的addAdvice方法,addAdvice方法在AdvisedSupport类中:

public void addAdvice(Advice advice) throws AopConfigException {
	//advisors是Advice列表,是一个LinkedList
    //如果被添加进来的是一个Interceptor,会先被包装成一个Advice
    //添加之前现获取advisor的大小,当做添加的Advice的位置
    int pos = (this.advisors != null) ? this.advisors.size() : 0;
    //添加Advice
    addAdvice(pos, advice);
}

接着看addAdvice(pos, advice)方法:

public void addAdvice(int pos, Advice advice) throws AopConfigException {
	//只能处理实现了AOP联盟的接口的拦截器
    if (advice instanceof Interceptor && !(advice instanceof MethodInterceptor)) {
        throw new AopConfigException(getClass().getName() + " only handles AOP Alliance MethodInterceptors");
    }
	//IntroductionInfo接口类型,表示引介信息
    if (advice instanceof IntroductionInfo) {
    	//不需要IntroductionAdvisor
        addAdvisor(pos, new DefaultIntroductionAdvisor(advice, (IntroductionInfo) advice));
    }
    //动态引介增强的处理
    else if (advice instanceof DynamicIntroductionAdvice) {
        //需要IntroductionAdvisor
        throw new AopConfigException("DynamicIntroductionAdvice may only be added as part of IntroductionAdvisor");
    }
    else {
    	//添加增强器,需要先把我们的增强包装成增强器,然后添加
        addAdvisor(pos, new DefaultPointcutAdvisor(advice));
    }
}

我们看到添加增强的时候,实际调用添加增强器这个方法,首先需要把我们的Advice包装成一个PointCutAdvisor,然后在添加增强器。这里先了解一下有关PointCutAdvisor的相关信息。

Advisor接口
Advisor,增强器,它持有一个增强Advice,还持有一个过滤器,来决定Advice可以用在哪里。

public interface Advisor {
	
	//判断Advice是不是每个实例中都有
	boolean isPerInstance();
	
	//返回持有的Advice
	Advice getAdvice();

}

PointcutAdvisor
是一个持有Pointcut切点的增强器,PointcutAdvisor现在就会持有一个Advice和一个Pointcut。

public interface PointcutAdvisor extends Advisor {

	//获取Pointcut
	Pointcut getPointcut();

}

Pointcut接口
切入点,定义了哪些连接点需要被织入横切逻辑。可以

public interface Pointcut {
	//类过滤器,可以知道哪些类需要拦截
	ClassFilter getClassFilter();
	//方法匹配器,可以知道哪些方法需要拦截
	MethodMatcher getMethodMatcher();
	
	// could add getFieldMatcher() without breaking most existing code
	Pointcut TRUE = TruePointcut.INSTANCE; 

}

ClassFilter接口

public interface ClassFilter {
	
	//判断给定的类是不是要拦截
	boolean matches(Class clazz);

	ClassFilter TRUE = TrueClassFilter.INSTANCE;

}

MethodMatcher接口

public interface MethodMatcher {
	
	/ 静态方法匹配
	boolean matches(Method m, Class targetClass);
	
	//是否是运行时动态匹配
	boolean isRuntime();
	
	//运行是动态匹配
	boolean matches(Method m, Class targetClass, Object[] args);
	
	MethodMatcher TRUE = TrueMethodMatcher.INSTANCE;

}

看完相关的定义之后,接着看方法new DefaultPointcutAdvisor(advice),将Advice包装成一个DefaultPointcutAdvisor。其实就是将advice和默认的Pointcut包装进DefaultPointcutAdvisor。

DefaultPointcutAdvisor是Advisor的最常用的一个实现,可以使用任意类型的Pointcut和Advice,但是不能使用Introduction。

构造完成了DefaultPointcutAdvisor只有,接着就是添加增强器方法addAdvisor:

public void addAdvisor(int pos, Advisor advisor) throws AopConfigException {
	//引介增强器处理
    if (advisor instanceof IntroductionAdvisor) {
        addAdvisor(pos, (IntroductionAdvisor) advisor);
    }
    else {
    	//其他的增强器处理
        addAdvisorInternal(pos, advisor);
    }
}

首先看下非引介增强器的添加方法addAdvisorInternal:

private void addAdvisorInternal(int pos, Advisor advice) throws AopConfigException {
    if (isFrozen()) {
        throw new AopConfigException("Cannot add advisor: config is frozen");
    }
    //把Advice添加到LinkedList中指定位置
    this.advisors.add(pos, advice);
    //同时更新一下Advisors数组
    updateAdvisorArray();
    //通知监听器
    adviceChanged();
}

然后看下关于引介增强器的添加addAdvisor,我们知道引介就是对目标类增加新的接口,所以引介增强,也就是对接口的处理:

public void addAdvisor(int pos, IntroductionAdvisor advisor) throws AopConfigException {
	//对接口进行校验
    advisor.validateInterfaces();

    // 遍历要添加的接口,添加
    for (int i = 0; i < advisor.getInterfaces().length; i++) {
    	//就是添加到interfaces集合中,interfaces是一个HashSet
        addInterface(advisor.getInterfaces()[i]);
    }
    //然后添加到advisors中
    addAdvisorInternal(pos, advisor);
}

对于添加增强的步骤,就是把我们的增强器添加进代理工厂中,保存在一个LinkedList中,顺序是添加进来的顺序。

获取代理
到目前为止,我们看到的都还是在组装代理工厂,并没有看到代理的生成,接下来proxyFactory.getProxy()这一步就是获取代理的过程,我们继续看ProxyFactory的getProxy方法:

public Object getProxy() {
	//创建一个AOP代理
    AopProxy proxy = createAopProxy();
    //返回代理
    return proxy.getProxy();
}

我们知道一般创建代理会有两种方式,一种是JDK动态代理,另外一种是CGLIB动态代理,而这里的创建AOP代理就是生成这两种代理中的一种。先看createAopProxy()方法,在AdvisedSupport类中:

protected synchronized AopProxy createAopProxy() {
    if (!this.isActive) {
        activate();
    }
    //获取AOP代理工厂,然后创建代理
    return getAopProxyFactory().createAopProxy(this);
}

获取代理工厂这一步,这里就是默认获取一个DefaultAopProxyFactory实例,然后调用createAopProxy创建AOP代理:

public AopProxy createAopProxy(AdvisedSupport advisedSupport) throws AopConfigException {
	//对于指定了使用CGLIB方式,或者代理的是类,或者代理的不是接口,就使用CGLIB的方式来创建代理
    boolean useCglib = advisedSupport.getOptimize() || advisedSupport.getProxyTargetClass() || advisedSupport.getProxiedInterfaces().length == 0;
    if (useCglib) {
        return CglibProxyFactory.createCglibProxy(advisedSupport);
    }
    else {
        //使用JDK动态代理来创建代理
        return new JdkDynamicAopProxy(advisedSupport);
    }
}

获取完AOP代理之后返回,然后就是调用getProxy方法获取代理,这里分为CGLIB的获取方式和JDK动态代理的获取方式两种。

JDK动态代理方式获取代理
JDK动态代理方式获取代理,实现在JdkDynamicAopProxy中:

public Object getProxy() {
    return getProxy(Thread.currentThread().getContextClassLoader());
}
public Object getProxy(ClassLoader cl) {
	//JDK动态代理只能代理接口类型,先获取接口
    //就是从AdvisedSupport中获取保存在interfaces中的接口
    Class[] proxiedInterfaces = AopProxyUtils.completeProxiedInterfaces(this.advisedSupport);
    //使用Java的反射机制创建一个代理实例
    return Proxy.newProxyInstance(cl, proxiedInterfaces, this);
}

关于JDK反射创建代理之类的,这里不做解析。

CGLIB方式获取代理
CGLIB获取方式,实现在Cglib2AopProxy中:

public Object getProxy() {
	//使用CGLIB的方式来获取,CGLIB这里不做解析
    return getProxy(Thread.currentThread().getContextClassLoader());
}

使用代理
上面获取代理之后,就剩最后一步,使用,当我们调用业务方法的时候,实际上是调用代理中的方法,对于CGLIB生成的代理,调用的是DynamicAdvisedInterceptor的intercept方法;JDK动态代理生成的代理是调用invoke方法。

JDK动态代理
看下JDK动态代理的方式,对于方法的调用,实际上调用的是代理类的invoke方法,在JdkDynamicAopProxy中:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    MethodInvocation invocation = null;
    Object oldProxy = null;
    boolean setProxyContext = false;
	//代理的目标对象
    TargetSource targetSource = advisedSupport.targetSource;
    Class targetClass = null;
    Object target = null;		

    try {
        //equals方法
        if (method.getDeclaringClass() == Object.class && "equals".equals(method.getName())) {
            return equals(args[0]) ? Boolean.TRUE : Boolean.FALSE;
        }
        else if (Advised.class == method.getDeclaringClass()) {
            //???
            return AopProxyUtils.invokeJoinpointUsingReflection(this.advisedSupport, method, args);
        }

        Object retVal = null;

        //代理目标对象
        target = targetSource.getTarget();
        if (target != null) {
            targetClass = target.getClass();
        }
		//???
        if (this.advisedSupport.exposeProxy) {
            // Make invocation available if necessary
            oldProxy = AopContext.setCurrentProxy(proxy);
            setProxyContext = true;
        }

        //获取配置的通知Advicelian
        List chain = this.advisedSupport.advisorChainFactory.getInterceptorsAndDynamicInterceptionAdvice(
                this.advisedSupport, proxy, method, targetClass);

        //没有配置通知
        if (chain.isEmpty()) {
            //直接调用目标对象的方法
            retVal = AopProxyUtils.invokeJoinpointUsingReflection(target, method, args);
        }
        else {
            //配置了通知,创建一个MethodInvocation
            invocation = new ReflectiveMethodInvocation(proxy, target,
                                method, args, targetClass, chain);

            //执行通知链,沿着通知器链调用所有的通知
            retVal = invocation.proceed();
        }

        //返回值
        if (retVal != null && retVal == target) {
            //返回值为自己
            retVal = proxy;
        }
        //返回
        return retVal;
    }
    finally {
        if (target != null && !targetSource.isStatic()) {
            targetSource.releaseTarget(target);
        }

        if (setProxyContext) {
            AopContext.setCurrentProxy(oldProxy);
        }
    }
}

CGLIB动态代理
CGLIB的是调用DynamicAdvisedInterceptor的intercept方法对目标对象进行处理,具体暂先不解析。

使用ProxyFactoryBean创建AOP代理
ProxyFactoryBean对Pointcut和Advice提供了完全的控制,还包括应用的顺序。ProxyFactoryBean的getObject方法会返回一个AOP代理,包装了目标对象。

Spring在初始化的过程中,createBean的时候,如果是FactoryBean的话,会调用((BeanFactoryAware)bean).setBeanFactory(this);:

public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
    this.beanFactory = beanFactory;
    //创建通知器链
    this.createAdvisorChain();
    if(this.singleton) {
    	//刷新目标对象
        this.targetSource = this.freshTargetSource();
        //获取单例实例
        this.getSingletonInstance();
        this.addListener(this);
    }

}

看下获取单例实例的方法:

private Object getSingletonInstance() {
    if(this.singletonInstance == null) {
        this.singletonInstance = this.createAopProxy().getProxy();
    }

    return this.singletonInstance;
}

createAopProxy方法在AdvisedSupport类中,下面创建的流程跟上面解析的都一样了。

SpringMVC

【Spring】Spring MVC原理及配置

1.Spring MVC概述:
Spring MVC是Spring提供的一个强大而灵活的web框架。借助于注解,Spring MVC提供了几乎是POJO的开发模式,使得控制器的开发和测试更加简单。这些控制器一般不直接处理请求,而是将其委托给Spring上下文中的其他bean,通过Spring的依赖注入功能,这些bean被注入到控制器中。

Spring MVC主要由DispatcherServlet、处理器映射、处理器(控制器)、视图解析器、视图组成。他的两个核心是两个核心:

处理器映射:选择使用哪个控制器来处理请求
视图解析器:选择结果应该如何渲染

通过以上两点,Spring MVC保证了如何选择控制处理请求和如何选择视图展现输出之间的松耦合。

2.SpringMVC运行原理

JAVA高级面试题——2019_第86张图片(1) Http请求:客户端请求提交到DispatcherServlet。
(2) 寻找处理器:由DispatcherServlet控制器查询一个或多个HandlerMapping,找到处理请求的Controller。
(3) 调用处理器:DispatcherServlet将请求提交到Controller。
(4)(5)调用业务处理和返回结果:Controller调用业务逻辑处理后,返回ModelAndView。
(6)(7)处理视图映射并返回模型: DispatcherServlet查询一个或多个ViewResoler视图解析器,找到ModelAndView指定的视图。
(8) Http响应:视图负责将结果显示到客户端。

3.SpringMVC接口解释
(1)DispatcherServlet接口:
Spring提供的前端控制器,所有的请求都有经过它来统一分发。在DispatcherServlet将请求分发给Spring Controller之前,需要借助于Spring提供的HandlerMapping定位到具体的Controller。
(2)HandlerMapping接口:
能够完成客户请求到Controller映射。
(3)Controller接口:
需要为并发用户处理上述请求,因此实现Controller接口时,必须保证线程安全并且可重用。
Controller将处理用户请求,这和Struts Action扮演的角色是一致的。一旦Controller处理完用户请求,则返回ModelAndView对象给DispatcherServlet前端控制器,ModelAndView中包含了模型(Model)和视图(View)。
从宏观角度考虑,DispatcherServlet是整个Web应用的控制器;从微观考虑,Controller是单个Http请求处理过程中的控制器,而ModelAndView是Http请求过程中返回的模型(Model)和视图(View)。
(4)ViewResolver接口:
Spring提供的视图解析器(ViewResolver)在Web应用中查找View对象,从而将相应结果渲染给客户。

4.DispatcherServlet:
是整个Spring MVC的核心。它负责接收HTTP请求组织协调Spring MVC的各个组成部分。其主要工作有以下三项:
(1)截获符合特定格式的URL请求。
(2)初始化DispatcherServlet上下文对应WebApplicationContext,并将其与业务层、持久化层的WebApplicationContext建立关联。
(3)初始化Spring MVC的各个组成组件,并装配到DispatcherServlet中。

5. SpringMVC配置
项目整体结构如下:

JAVA高级面试题——2019_第87张图片(1)在web.xml文件中进行配置,在配置中设置springmvc-context.xml的路径,代码如下:

<servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:META-INF/spring/springmvc-context.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

(2)配置springmvc-context.xml文件,这一部分主要是开启注解功能、配置试图解析器,代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:p="http://www.springframework.org/schema/p"
    xsi:schemaLocation="http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd ">
    <mvc:annotation-driven />
    <!-- ①:对web包中的所有类进行扫描,以完成Bean创建和自动依赖注入的功能 -->
    <context:component-scan base-package="com.zjn" />

    <!-- 这两个类用来启动基于Spring MVC的注解功能,将控制器与方法映射加入到容器中 -->
    <beans:bean
        class="org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping" />
    <beans:bean
        class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" />

    <!-- 这个类用于Spring MVC视图解析 -->
    <beans:bean id="viewResolver"
        class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <beans:property name="prefix" value="/WEB-INF/pages/" />
        <beans:property name="suffix" value=".jsp" />
    </beans:bean>

</beans:beans>

(3)配置文件完成了,下面开始写代码,
两个jsp界面:
create.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Add User From</title>
</head>
<body>
    <form action="save" method="post">
        <fieldset>
        <legend>创建用户</legend>
            <p>
                <label>姓名:</label> <input type="text" id="name" name="name"
                    tabindex="1">
            </p>
            <p>
                <label>年龄:</label> <input type="text" id="age" name="age"
                    tabindex="2">
            </p>
            <p>
                <label>密码:</label> <input type="text" id="pwd" name="pwd"
                    tabindex="3">
            </p>
            <p id="buttons">
                <input id="reset" type="reset" tabindex="4" value="取消"> <input
                    id="submit" type="submit" tabindex="5" value="创建">
            </p>
        </fieldset>
    </form>
</body>
</html>

detail.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
</head>
<body>
    <div id="gloobal">
        <h4>创建成功</h4>
        <p>
        <h5>详情:</h5>
        姓名:${user.name}<br /> 年龄:${user.age}<br /> 密码:${user.pwd}<br />
        </p>
    </div>
</body>
</html>

UserController.java

package com.zjn.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;

import com.zjn.entity.User;

/**
 * 用户管理
 * 
 * @author zjn
 */
@Controller
public class UserController {

    @RequestMapping("")
    public String Create(Model model) {
        return "create";
    }

    @RequestMapping("/save")
    public String Save(@ModelAttribute("form") User user, Model model) { // user:视图层传给控制层的表单对象;model:控制层返回给视图层的对象
        model.addAttribute("user", user);
        return "detail";
    }
}

User.java

package com.zjn.entity;

import java.io.Serializable;
import java.util.Date;

public class User implements Serializable {
    /**
     * @author zjn
     */
    private static final long serialVersionUID = 1L;
    private Integer id; // id
    private String name; // name
    private String pwd; // pwd
    private Integer age; // age
    private Date creatTime; // creatTime

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPwd() {
        return pwd;
    }

    public void setPwd(String pwd) {
        this.pwd = pwd;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public Date getCreatTime() {
        return creatTime;
    }

    public void setCreatTime(Date creatTime) {
        this.creatTime = creatTime;
    }

}

(4)运行结果

初始页面:

JAVA高级面试题——2019_第88张图片
输入参数:

JAVA高级面试题——2019_第89张图片
点击创建:

JAVA高级面试题——2019_第90张图片
SpringMVC源码分析系列:

SpringMVC源码解析(1)-启动过程
SpringMVC源码解析(2)-DispatcherServlet
SpringMVC源码解析(3)-HandleMapping
SpringMVC源码解析(4)-HandlerAdapter
SpringMVC源码解析(5)-HandlerExceptionResolver
SpringMVC源码解析(6)-异步请求

Spring中运用了那些设计模式

spring中常用的设计模式达到九种,我们举例说明:

第一种:简单工厂

又叫做静态工厂方法(StaticFactory Method)模式,但不属于23种GOF设计模式之一。
简单工厂模式的实质是由一个工厂类根据传入的参数,动态决定应该创建哪一个产品类。
spring中的BeanFactory就是简单工厂模式的体现,根据传入一个唯一的标识来获得bean对象,但是否是在传入参数后创建还是传入参数前创建这个要根据具体情况来定。如下配置,就是在 HelloItxxz 类中创建一个 itxxzBean。

<beans>
    <bean id="singletonBean" class="com.itxxz.HelloItxxz">
        <constructor-arg>
            <value>Hello! 这是singletonBean!value>
        </constructor-arg>
   </ bean>
 
    <bean id="itxxzBean" class="com.itxxz.HelloItxxz"
        singleton="false">
        <constructor-arg>
            <value>Hello! 这是itxxzBean! value>
        </constructor-arg>
    </bean>
 
</beans>

第二种:工厂方法(Factory Method)

通常由应用程序直接使用new创建新的对象,为了将对象的创建和使用相分离,采用工厂模式,即应用程序将对象的创建及初始化职责交给工厂对象。
一般情况下,应用程序有自己的工厂对象来创建bean.如果将应用程序自己的工厂对象交给Spring管理,那么Spring管理的就不是普通的bean,而是工厂Bean。
螃蟹就以工厂方法中的静态方法为例讲解一下:

import java.util.Random;
public class StaticFactoryBean {
      public static Integer createRandom() {
           return new Integer(new Random().nextInt());
       }
}

建一个config.xm配置文件,将其纳入Spring容器来管理,需要通过factory-method指定静态方法名称

<bean id="random"
class="example.chapter3.StaticFactoryBean" factory-method="createRandom" //createRandom方法必须是static的,才能找到 scope="prototype"
/>

测试:

public static void main(String[] args) {
      //调用getBean()时,返回随机数.如果没有指定factory-method,会返回StaticFactoryBean的实例,即返回工厂Bean的实例       XmlBeanFactory factory = new XmlBeanFactory(new ClassPathResource("config.xml"));       System.out.println("我是IT学习者创建的实例:"+factory.getBean("random").toString());
}

第三种:单例模式(Singleton)

保证一个类仅有一个实例,并提供一个访问它的全局访问点。
spring中的单例模式完成了后半句话,即提供了全局的访问点BeanFactory。但没有从构造器级别去控制单例,这是因为spring管理的是是任意的java对象。
核心提示点:Spring下默认的bean均为singleton,可以通过singleton=“true|false” 或者 scope=“?”来指定

第四种:适配器(Adapter)

在Spring的Aop中,使用的Advice(通知)来增强被代理类的功能。Spring实现这一AOP功能的原理就使用代理模式(1、JDK动态代理。2、CGLib字节码生成技术代理。)对类进行方法级别的切面增强,即,生成被代理类的代理类, 并在代理类的方法前,设置拦截器,通过执行拦截器重的内容增强了代理方法的功能,实现的面向切面编程。

Adapter类接口:Target

public interface AdvisorAdapter {
 
boolean supportsAdvice(Advice advice);
 
      MethodInterceptor getInterceptor(Advisor advisor);
 
} 

MethodBeforeAdviceAdapter类,Adapter

class MethodBeforeAdviceAdapter implements AdvisorAdapter, Serializable {
 
      public boolean supportsAdvice(Advice advice) {
            return (advice instanceof MethodBeforeAdvice);
      }
 
      public MethodInterceptor getInterceptor(Advisor advisor) {
            MethodBeforeAdvice advice = (MethodBeforeAdvice) advisor.getAdvice();
      return new MethodBeforeAdviceInterceptor(advice);
      }
 
}

第五种:包装器(Decorator)

在我们的项目中遇到这样一个问题:我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。我们以往在spring和hibernate框架中总是配置一个数据源,因而sessionFactory的dataSource属性总是指向这个数据源并且恒定不变,所有DAO在使用sessionFactory的时候都是通过这个数据源访问数据库。但是现在,由于项目的需要,我们的DAO在访问sessionFactory的时候都不得不在多个数据源中不断切换,问题就出现了:如何让sessionFactory在执行数据持久化的时候,根据客户的需求能够动态切换不同的数据源?我们能不能在spring的框架下通过少量修改得到解决?是否有什么设计模式可以利用呢?
首先想到在spring的applicationContext中配置所有的dataSource。这些dataSource可能是各种不同类型的,比如不同的数据库:Oracle、SQL Server、MySQL等,也可能是不同的数据源:比如apache 提供的org.apache.commons.dbcp.BasicDataSource、spring提供的org.springframework.jndi.JndiObjectFactoryBean等。然后sessionFactory根据客户的每次请求,将dataSource属性设置成不同的数据源,以到达切换数据源的目的。
spring中用到的包装器模式在类名上有两种表现:一种是类名中含有Wrapper,另一种是类名中含有Decorator。基本上都是动态地给一个对象添加一些额外的职责。

第六种:代理(Proxy)

为其他对象提供一种代理以控制对这个对象的访问。 从结构上来看和Decorator模式类似,但Proxy是控制,更像是一种对功能的限制,而Decorator是增加职责。
spring的Proxy模式在aop中有体现,比如JdkDynamicAopProxy和Cglib2AopProxy。

第七种:观察者(Observer)

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
spring中Observer模式常用的地方是listener的实现。如ApplicationListener。

第八种:策略(Strategy)

定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式使得算法可独立于使用它的客户而变化。
spring中在实例化对象的时候用到Strategy模式
在SimpleInstantiationStrategy中有如下代码说明了策略模式的使用情况:

JAVA高级面试题——2019_第91张图片
第九种:模板方法(Template Method)

定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。Template Method使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
Template Method模式一般是需要继承的。这里想要探讨另一种对Template Method的理解。spring中的JdbcTemplate,在用这个类时并不想去继承这个类,因为这个类的方法太多,但是我们还是想用到JdbcTemplate已有的稳定的、公用的数据库连接,那么我们怎么办呢?我们可以把变化的东西抽出来作为一个参数传入JdbcTemplate的方法中。但是变化的东西是一段代码,而且这段代码会用到JdbcTemplate中的变量。怎么办?那我们就用回调对象吧。在这个回调对象中定义一个操纵JdbcTemplate中变量的方法,我们去实现这个方法,就把变化的东西集中到这里了。然后我们再传入这个回调对象到JdbcTemplate,从而完成了调用。这可能是Template Method不需要继承的另一种实现方式吧。

以下是一个具体的例子:
JdbcTemplate中的execute方法

JAVA高级面试题——2019_第92张图片

JdbcTemplate执行execute方法

JAVA高级面试题——2019_第93张图片

Spring中的Bean是线程安全的吗

Spring单例不是线程安全的。Spring通过ThreadLocal实现单例bean的线程安全
Spring容器中的bean默认是单例模式。当多个客户端同时请求一个服务时,容器会给每一个请求分配一个线程。这些线程会并发执行该请求对应的业务处理逻辑(成员方法),如果该处理逻辑中有对该单例bean状态的修改(体现为该单例bean的成员属性),则需要考虑线程同步问题。
Spring使用ThreadLocal解决线程安全问题。一般情况下,只有无状态的Bean才可以在多线程环境下共享。Spring对一些Bean(如RequestContextHolder、TransactionSynchronizationManager、LocaleContextHolder等)中“非线程安全状态”,采用ThreadLocal进行处理,让它们也成为线程安全的状态,因此有状态的Bean也可以在多线程中共享了。
ThreadLocal和线程同步机制(synchronized)都可以解决多线程中“相同变量”的访问冲突问题。
线程同步机制(synchronized)通过“对象锁”,保证同一时间只有一个线程访问变量,让不同的线程排队访问。这时该变量是多个线程共享的,使用同步机制要求程序慎密地分析什么时候对变量进行读写,什么时候需要锁定某个对象,什么时候释放对象锁等繁杂的问题,程序设计和编写难度相对较大。
ThreadLocal会为每一个线程提供一个独立的“变量副本”,从而隔离了多个线程对数据的访问冲突。因为每一个线程都拥有自己的变量副本,从而也就没有必要对该变量进行同步了。ThreadLocal提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。

如果某一进程中有多个线程在同时运行,而这些线程又同时运行某段代码。假如每次运行结果和单线程运行的结果是一样的,而且其他变量的值也和预期的是一样的,就是线程安全的。如果一个类所提供的方法,对于线程来说是原子操作,或者多个线程之间的切换不会导致该接口的执行结果存在二义性,那么便不用考虑同步的问题。
线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,这个全局变量是线程安全的。若有多个线程同时执行写操作,则需要考虑线程同步,否则就可能影响线程安全。
① 常量始终是线程安全的,因为只存在读操作。
② 每次调用方法前都新建一个实例是线程安全的,因为不会访问共享的资源。
③ 局部变量是线程安全的。因为每执行一个方法,都会在独立的空间创建局部变量,它不是共享的资源。局部变量包括方法的参数变量和方法内变量。
有状态就是有数据存储功能。有状态对象(Stateful Bean),就是有实例变量的对象,可以保存数据,是非线程安全的。在不同方法调用间不保留任何状态。
无状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象,不能保存数据,是不变类,是线程安全的。

Spring中的Bean的作用域

singleton : bean在每个Spring ioc 容器中只有一个实例。

prototype:一个bean的定义可以有多个实例。

request:每次http请求都会创建一个bean,该作用域仅在基于web的Spring ApplicationContext情形下有效。

session:在一个HTTP Session中,一个bean定义对应一个实例。该作用域仅在基于web的Spring ApplicationContext情形下有效。

global-session:在一个全局的HTTP Session中,一个bean定义对应一个实例。该作用域仅在基于web的Spring ApplicationContext情形下有效。

缺省的Spring bean 的作用域是Singleton

Spring中的Bean的生命周期

简单来说如下:
Bean的建立
由BeanFactory读取Bean定义文件,并生成各个实例。

Setter注入
执行Bean的属性依赖注入。

BeanNameAware的setBeanName()
如果Bean类实现了org.springframework.beans.factory.BeanNameAware接口,则执行其setBeanName()方法。

BeanFactoryAware的setBeanFactory()
如果Bean类实现了org.springframework.beans.factory.BeanFactoryAware接口,则执行其setBeanFactory()方法。

BeanPostProcessors的processBeforeInitialization()
容器中如果有实现org.springframework.beans.factory.BeanPostProcessors接口的实例,则任何Bean在初始化之前都会执行这个实例的processBeforeInitialization()方法。

InitializingBean的afterPropertiesSet()
如果Bean类实现了org.springframework.beans.factory.InitializingBean接口,则执行其afterPropertiesSet()方法。

Bean定义文件中定义init-method时会执行initMethod()方法,注意,这个方法是不带参数的。

BeanPostProcessors的processAfterInitialization()
容器中如果有实现org.springframework.beans.factory.BeanPostProcessors接口的实例,则任何Bean在初始化之前都会执行这个实例的processAfterInitialization()方法。

DisposableBean的destroy()
在容器关闭时,如果Bean类实现了org.springframework.beans.factory.DisposableBean接口,则执行它的destroy()方法。

Bean定义文件中定义destroy-method在容器关闭时,可以在Bean定义文件中使用“destory-method”定义的方法。

详细:
Spring学习10- bean的生命周期(阿里面试题目两次面试均提到)
找工作的时候有些人会被问道Spring中Bean的生命周期,其实也就是考察一下对Spring是否熟悉,工作中很少用到其中的内容,那我们简单看一下。

在说明前可以思考一下Servlet的生命周期:实例化,初始init,接收请求service,销毁destroy;

可以参考博文:Servlet 生命周期、工作原理

Spring上下文中的Bean也类似,如下

1、实例化一个Bean--也就是我们常说的new;

2、按照Spring上下文对实例化的Bean进行配置--也就是IOC注入;

3、如果这个Bean已经实现了BeanNameAware接口,会调用它实现的setBeanName(String)方法,此处传递的就是Spring配置文件中Bean的id值

4、如果这个Bean已经实现了BeanFactoryAware接口,会调用它实现的setBeanFactory(setBeanFactory(BeanFactory)传递的是Spring工厂自身(可以用这个方式来获取其它Bean,只需在Spring配置文件中配置一个普通的Bean就可以);

5、如果这个Bean已经实现了ApplicationContextAware接口,会调用setApplicationContext(ApplicationContext)方法,传入Spring上下文(同样这个方式也可以实现步骤4的内容,但比4更好,因为ApplicationContext是BeanFactory的子接口,有更多的实现方法); 

6、如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessBeforeInitialization(Object obj, String s)方法,BeanPostProcessor经常被用作是Bean内容的更改,并且由于这个是在Bean初始化结束时调用那个的方法,也可以被应用于内存或缓存技术;(阿里面试官问BeanPostProcessor是做什么用的)

7、如果Bean在Spring配置文件中配置了init-method属性会自动调用其配置的初始化方法。

8、如果这个Bean关联了BeanPostProcessor接口,将会调用postProcessAfterInitialization(Object obj, String s)方法、;

注:以上工作完成以后就可以应用这个Bean了,那这个Bean是一个Singleton的,所以一般情况下我们调用同一个id的Bean会是在内容地址相同的实例,当然在Spring配置文件中也可以配置非Singleton,这里我们不做赘述。

9、当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean这个接口,会调用那个其实现的destroy()方法;

10、最后,如果这个Bean的Spring配置中配置了destroy-method属性,会自动调用其配置的销毁方法。 

以上10步骤可以作为面试或者笔试的模板,另外我们这里描述的是应用Spring上下文Bean的生命周期,如果应用Spring的工厂也就是BeanFactory的话去掉第5步就Ok了。

Spring 中bean 的生命周期短暂吗?

在spring中,从BeanFactory或ApplicationContext取得的实例为Singleton,也就是预设为每一个Bean的别名只能维持一个实例,而不是每次都产生一个新的对象使用Singleton模式产生单一实例,对单线程的程序说并不会有什么问题,但对于多线程的程序,就必须注意安全(Thread-safe)的议题,防止多个线程同时存取共享资源所引发的数据不同步问题。

然而在spring中 可以设定每次从BeanFactory或ApplicationContext指定别名并取得Bean时都产生一个新的实例:例如:

在spring中,singleton属性默认是true,只有设定为false,则每次指定别名取得的Bean时都会产生一个新的实例

一个Bean从创建到销毁,如果是用BeanFactory来生成,管理Bean的话,会经历几个执行阶段(如图1.1):

JAVA高级面试题——2019_第94张图片

1:Bean的建立:

容器寻找Bean的定义信息并将其实例化。

2:属性注入:

使用依赖注入,Spring按照Bean定义信息配置Bean所有属性

3:BeanNameAware的setBeanName():

如果Bean类有实现org.springframework.beans.BeanNameAware接口,工厂调用Bean的setBeanName()方法传递Bean的ID。

4:BeanFactoryAware的setBeanFactory():

如果Bean类有实现org.springframework.beans.factory.BeanFactoryAware接口,工厂调用setBeanFactory()方法传入工厂自身。

5:BeanPostProcessors的ProcessBeforeInitialization()

如果有org.springframework.beans.factory.config.BeanPostProcessors和Bean关联,那么其postProcessBeforeInitialization()方法将被将被调用。

6:initializingBean的afterPropertiesSet():

如果Bean类已实现org.springframework.beans.factory.InitializingBean接口,则执行他的afterProPertiesSet()方法

7:Bean定义文件中定义init-method:

可以在Bean定义文件中使用"init-method"属性设定方法名称例如:

如果有以上设置的话,则执行到这个阶段,就会执行initBean()方法

8:BeanPostProcessors的ProcessaAfterInitialization()

如果有任何的BeanPostProcessors实例与Bean实例关联,则执行BeanPostProcessors实例的ProcessaAfterInitialization()方法

此时,Bean已经可以被应用系统使用,并且将保留在BeanFactory中直到它不在被使用。有两种方法可以将其从BeanFactory中删除掉(如图1.2):
JAVA高级面试题——2019_第95张图片

1:DisposableBean的destroy()

在容器关闭时,如果Bean类有实现org.springframework.beans.factory.DisposableBean接口,则执行他的destroy()方法

2:Bean定义文件中定义destroy-method

在容器关闭时,可以在Bean定义文件中使用"destroy-method"属性设定方法名称,例如:

如果有以上设定的话,则进行至这个阶段时,就会执行destroy()方法,如果是使用ApplicationContext来生成并管理Bean的话则稍有不同,使用ApplicationContext来生成及管理Bean实例的话,在执行BeanFactoryAware的setBeanFactory()阶段后,若Bean类上有实现org.springframework.context.ApplicationContextAware接口,则执行其setApplicationContext()方法,接着才执行BeanPostProcessors的ProcessBeforeInitialization()及之后的流程。

Spring事务的传播行为

事务知识点总结参考:https://blog.csdn.net/longzhutengyue/article/details/95754184

Spring事务默认抛出什么异常

SpringBoot

SpringBoot实现原理

RestController与Controller区别

Mybatis

Mybatis缓存怎么用

Mybatis中运用了那些设计模式

静态代理、JDK动态代理与cglib动态代理区别

分布式架构

SpringCloud

Double

Double原理

https://blog.csdn.net/qq_33101675/article/details/78701305

Dubbo RPC原理解读

https://blog.csdn.net/wuzhengfei1112/article/details/77142147

Double是如何实现负载均衡的

Double如何实现异步调用

SpringCloud与Double区别?

分布式事物实现原理

分布式缓存实现原理

分布式锁实现原理

分布式如何解决数据一致性

中间件

Redis

Redis如何做高可用?

Redis如何实现队列?

Redis如何做持久化?

Redis做持久化为什么会降低性能?

Redis哨兵机制?

Redis分片

Redis单线程为什么快?

Redis与Nginx、Memcached区别?

RabbitMq

RabbitMq如何做高可用?

RabbitMq死信队列原理,应用场景?

RabbitMq如何保证消费顺序?

RabbitMq网络拓扑?

RabbitMq如何解决消息丢失?

RabbitMq多客户端下如何解决重复消费?

Nginx

Zookeeper

性能调优

JVM调优

tomcat调优

Nginx调优

数据库调优

参考借鉴:
https://www.cnblogs.com/whgk/p/6071617.html
https://www.jianshu.com/p/6d2645be55c1
https://blog.csdn.net/w760079528/article/details/77845267
https://blog.csdn.net/zhuangm_888/article/details/79744645
https://blog.csdn.net/zjltju1203/article/details/90174944

你可能感兴趣的:(面试)