Java基础知识
Java基础知识
1、Java基础
1.0、java和其他语言之间的区别:
java:
Java简单,易于设计,易于编写,因此比其他任何Java都易于编译,调试和学习。Java是面向对象的,用于构建模块化程序和其他应用程序中的可重用代码。Java与平台无关,可移植复制。
首先Java是一个面向对象的编程语言,容易理解。而且略去了多重加载、指针等难以理解的概念。并且实现了自动垃圾回收,大大简化了程序设计。
跨平台是Java最大的优势。Java运行在JVM(Java虚拟机)上,在任何平台只要安装了JVM。Java就可以运行。它架构在操作系统之上,屏蔽了底层的差异。真正实现了“Write once,run anywhere”。Java中没有指针,这样就没有办法直接访问内存了。
另外Java也不容易出现内存泄露。
Java内置对多线程的支持,可以方便地在程序中实现多线程的功能。不像其他不支持多线程的语言,需要调用操作系统的多线程功能才能完成多线程的实现。
C++:
C#:
python:
matlab:
1.1、说一说对java访问权限的了解
有四种访问修饰符可以修饰成员变量或成员方法:private、default、protected、public
- private:修饰的成员变量仅可以在本类中使用。
- 不写访问修饰符(default):修饰的成员变量可以在本类中使用,在同一个包下的其他类中也可以使用。
- protected:修饰的成员变量可以在本类中使用,可以在同一个包下的其他类中使用,还可以在当前类的子类(有继承关系)中使用。
- public:修饰的成员变量可以在项目的任意包、任意类中使用。
有两种访问修饰符可以修饰类:default、public
- 不写访问修饰符(default):可以在同一个包下使用。
- public:可以在项目下的任意包、任意类中使用。
1.2、说一说对java变量的理解(区别)
成员变量:成员变量是在类中定义,成员变量是有默认初始值,
被static修饰的叫静态变量也加类变量,是存储在方法区中,生命周期与当前类相同。
未被static修饰的成员变量叫做实例变量,存储在堆区中,生命周期与实例对象相同。
局部变量:局部变量是在方法中定义,局部变量没有默认初始值,(执行方法的时候,java虚拟机会创建一个栈帧,用于存储局部变量表),所以局部变量存储在栈内存中,作用范围结束、变量空间自动释放。
说一说对static关键字的理解?
static关键字可以修饰类、方法、变量、代码块。
修饰类:普通类是不允许声明为静态的,只有内部类才可以使用static修饰。
修饰方法:修饰方法的时候,可以直接通过类名来进行调用。
修饰变量:被static修饰的成员变量叫做静态变量,也叫做类变量,说明这个变量是属于这个类的,而不是属于是对象,没有被static修饰的成员变量叫做实例变量,说明这个变量是属于某个具体的对象的。静态变量存放在方法区中,并且是被所有线程所共享的。
修饰代码块:静态代码块在类第一次被载入时执行。执行顺序:父类静态变量—>父类静态代码块—>子类静态变量—>子类静态代码块—>父类普通变量—>父类普通代码块—>父类构造函数—>子类普通变量—>子类普通代码块—>子类构造函数。
成员变量和静态变量的区别
- 成员变量随着对象的创建而存在随着对象的回收而释放。静态变量随着类的加载而存在随着类的消失而消失。
- 成员变量只能被对象调用。静态变量可以被对象调用,也可以用类名调用。(推荐用类名调用)
- 成员变量也称为实例变量。静态变量称为类变量。
- 成员变量数据存储在堆内存的对象中,所以也叫对象的特有数据。静态变量数据存储在方法区(共享数据区)的静态区,所以也叫对象的共享数据。
1.3、了解java的基础类型有哪些吗?然后默认值是多少?然后了解基本类型对应的包装类吗?了解自动装箱与自动拆箱吗?
基础类型:(数字类型4种)byte、short、int、long、(浮点型2种)float、double、(其他)char、boolean
数据类型 包装类型 默认值
byte Byte 0
short Short 0
int Integer 0
long Long 0L
float Float 0.0F
double Double 0.0
char Character ‘\u0000’
boolean Boolean false
自动装箱:基本数据类型自动转换成包装类的过程,可以将一个基本数据类型直接赋值给对应的包装类。
自动拆箱:包装类自动转换成基本数据类型的过程,可以将一个包装类直接赋值给对应的基本数据类型。
包装类属于引用数据类型,Object是所有引用数据类型的超类。
1.4、说一说对面向对象的理解?
**整体理解:**面向对象是对现实世界的理解和抽象的方法。
**特征:**面向对象有三个主要特征分别是封装、继承、多态
**封装:**将类的某些信息隐藏在类内部,不允许外部程序直接访问,而是通过提供的方法来实现对隐藏信息的操作和访问
使用封装的好处:只能通过规定的方法来访问数据;
**继承:**继承是为了提高代码的复用性,在定义不同类的时候是存在相同的属性,为了方便可以将这些相同的属性都抽象成一个父类,子类直接继承父类,减少代码的重复定义,子类可以使用父类的非私有成员。
为什么不能多继承?java只能单继承
答:为什么不能多继承,继承的话,子类是继承父类的非私有成员,如果多继承的话,假如两个类有同名的方法,那么子类在调用该方法时,就会产生混淆,同样在重写的时候也会有混淆,因此java不支持多继承。
接口可以多继承吗?为什么?
答:java的接口可以多继承,几个接口可以有相同的实现类和实现方法,因为接口的成员变量都是 static final的,有自己静态域,只能自己使用。
**多态:**多态是指父类的引用指向子类的对象,无须任何类型转换,向上转型自动完成,在编译时的类型是父类的类型,而运行时的类型是子类的类型,调用方法时表现出子类的特征而不是父类的特征,呈现了不同的特征,这就是多态。
多态怎么实现?
多态的实现离不开继承,父类的引用指向子类的对象。
1.5、重写和重载的区别?
重写:重写是发生在子类与父类之间的关系,若子类相对父类的方法重写,那么必须保证方法名、参数列表相同,另外返回值可以小于等于父类的方法,抛出的异常要小于等于父类方法,访问修饰符要大于等于父类方法。此外,父类为private修饰的方法,子类是不能重写的。
重载:重载是发生在同一个类中,若多个方法之间方法名相同,参数列表不同,则构成重载关系(参数列表一定需要不同,参数类型不同或者数量不同或者不同参数类型之间的顺序不同)。重载与访问修饰符和返回值无关,不能根据访问修饰符以及返回值来判断是 否重载。
只有修饰符和返回值不一样算不算重载?
答:返回值类型不同或者修饰符不同的方法不算重载,编译的时候直接报方法名重复错
1.6、说说对String类的理解?
1.6.1 String的理解
先看String的源码:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { @Stable private final byte[] value; private final byte coder; private int hash; // Default to 0 private static final long serialVersionUID = -6849794470754667710L; static final boolean COMPACT_STRINGS;} 从源码中关于String类的定义可以知道String类是用final修饰的,意味着String类不能被继承,并且它的成员方法都默认为final方法。在Java中final修饰的类是不允许被继承的。从类里面关于变量的定义我们可以知道,String存储字符串是使用一个final修饰的byte[ ]数组,final修饰的变量是不允许被更改的,因此String一旦被创建就是不可变的,这时候对该字符串做的修改操作,比如拼接、裁剪、替换都会生成一个新的字符串。
字符串的分配和其他对象分配一样,是分配栈内存的,而且我们使用字符串的场景特别多,JVM为了提高性能和减少内存的开销,在实例化字符串的时候做了一些优化:使用字符串常量池,每当我们创建字符串常量时,JVM会首先检查字符串常量池,如果该字符已经在字符串常量池里面,那么直接返回常量池中的引用,如果不在的话,就会实例化字符串,并且将其放到常量池中。由于String的不可变性,我们能肯定的认为字符串常量池中不会存在两个相同的字符串。
注:常量池分为下面两种 静态常量池:是.class文件中的常量池,不仅仅包括字符串、数字的字面量,还包括类、方法的信息,占用class文件的绝大部分空间。 运行时常量池:是jvm类装载完成后,将class文件的常量池放入到内存中,并保存在方法区,我们常说的常量池,就是指方法区中的运行时常量池。字符串的比较?是否相等?
String s1 = "Runoob"; // String 直接创建String s2 = "Runoob"; // String 直接创建String s3 = s1; // 相同引用String s4 = new String("Runoob"); // String 对象创建String s5 = new String("Runoob"); // String 对象创建String s6 = "Run"+"oob"; // String 对象创建//s1、s2、s3、s4、s5是否相等?s1==s2? true; //s1和s2直接指向常量池,故相等s2==s3? true; //s4==s5? false;//s4是在堆区新建的对象,s5也是在堆区新建的对象,这是两个不同的对象,虽然存储的值是一样的s1==s4? false;//s4是在堆区新建的对象,s1直接指向常量池s1==s5? false;//同上s1==s6? true; //s6看String的底层源码可以看到,两个字符串常量值在相加的时候,底层会直接优化成一个字符串。1.6.2 String、StringBuffer、StringBuilder的区别?
String:字符串常量,每次对字符串修改都会创建一个新的字符串
StringBuilder:字符串变量,线程不安全,对字符串修改就在其对象本身上修改
StringBuffer:字符串变量,线程安全,对字符串修改就在其对象本身上修改
执行速度:在大部分情况下:StringBuilder>StringBuffer>String
1.6.3 String在jdk1.8和jdk1.9中的区别
private final byte[] value;private final byte coder;//jdk1.8private final char[] value; String的底层实现在jdk1.8时使用的char[ ]数组,在jdk1.9的时候使用的是byte[ ]数组,这是因为开发者发现人们使用拉丁符号居多,而拉丁符号只占一个字节,char一个字符占两个字节,那么就会造成内存的浪费,GC(垃圾回收)的更加频繁,浪费了系统资源。因此jdk1.9将char数组改成了byte数组,使用byte数组会产生一个问题,因为中文字符存储是需要占两个字节,在jdk1.9中使用了一个标志位coder表示该字符串的编码方式,判断是Latin还是UTF-16。
1.7、说一说对异常的理解?
1.7.1 错误 Error
错误一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败等,这种错误无法恢复以及捕获,将导致程序中断。
1.7.2 编译时异常 Checked
编译时异常是可以被处理的异常,必须被显式的处理,如果不处理的话,不能通过编译。
1.7.3 运行时异常 RuntimeException
运行时异常,在程序运行时发生的异常,可以不处理也能通过编译,但是运行时会报错,需要通过try catch finally捕获或者抛出异常。
异常的抛出:当程序出现错误时,系统会自动抛出异常,Java也允许程序员主动抛出异常,在业务代码中,判断某项错误的条件成立时,可以使用throw关键字向外抛出异常,在这种情况下,如果当前方法不知道该如何处理这个异常,可以在方法签名上通过throws关键字声明抛出异常,将异常交给JVM处理。
异常的处理:在java语句中,处理异常的语句由try、catch、finally三部分组成,其中try用于包裹可能出现异常的业务代码,catch用于捕获并处理某个类型的异常,finally用于后处理,例如资源的释放。当业务代码发生异常时,系统会为此异常创建一个异常对象,创建异常对象之后,JVM会寻找可以处理该异常的catch块并交给其处理。finally中的语句块是总会执行的,即在执行try、catch后总会执行finally块。(finally不执行的例外情况:使用 System.exit(1); 来退出虚拟机)
在try和finally里面都有return会执行哪个?
先执行try中的return,但不立即返回,而是去寻找是否有finally,如果有,继续执行finally中的代码,如果finally中没有return,那么执行完finally后回到try中执行return,如果finally中有return,那么直接返回。
1.7.4 怎么自定义异常?
- 创建一个自定义异常类继承Exception
- 在需要用到这个自定义异常类的地方使用 throw new 自定义异常类名(“异常信息”);
- 在方法上使用throws抛出这个自定义异常。
1.8、说一说对反射的理解?
编译:.java文件编译后生成.class字节码文件 加载:类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的java.lang.Class对象实例连接:细分三步 验证:格式(class文件规范) 语义(final类是否有子类) 操作 准备:静态变量赋初值和内存空间,final修饰的内存空间直接赋原值,此处不是用户指定的初值。 解析:符号引用转化为直接引用,分配地址 初始化:有父类先初始化父类,然后初始化自己;将static修饰代码执行一遍,如果是静态变量,则用用户指定值覆盖原有初值;如果是代码块,则执行一遍操作。 Java的反射就是利用上面第二步加载到jvm中的.class文件来进行操作的。.class文件中包含java类的所有信息,当你不知道某个类具体信息时,可以使用反射获取class,然后进行各种操作。
Java反射就是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;并且能改变它的属性。总结说:反射就是把java类中的各种成分映射成一个个的Java对象,并且可以进行操作。
1.8.1 什么是反射?
一般我们要使用某个已知类时,直接使用new一个对象实例化,这是正向的创建一个类的对象,如果一开始我们不知道要创建的类是什么的时候,自然也就无法通过new关键字来创建对象,这时候我们就需要利用反射,使用JDK提供的反射API来进行反射调用。
反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法。
1.8.2 通过反射获取class对象
- Class c = Class.forName(“类的全限定名称”);
- Class c = String.class;//事先就知道要操作的类
- Class c = 引用.getClass();//可以根据一个引用反向获取类的对象
通过反射创建对象:
-
通过Class对象的newInstance方法:
Class c = String.class;
String str = (String)c.newInstance();
-
通过Constructor对象的newInstance方法
Constructor constructor = c.getConstructor();
String str= (String )constructor.newInstance();
1.8.3 通过反射获取类属性、方法、构造器:
属性:我们通过 Class 对象的 getFields() 方法可以获取 Class 类的属性,但无法获取私有属性。
而如果使用 Class 对象的 getDeclaredFields() 方法则可以获取包括私有属性在内的所有属性。
Class clz = String.class;Field[] fields = clz.getFields();//获取非私有属性for (Field field : fields) { System.out.println(field.getName());}
Class clz = String.class;Field[] fields = clz.getDeclaredFields();//获取所有属性 包括私有的for (Field field : fields) { System.out.println(field.getName());}方法:我们通过 Class 对象的 getMethods() 方法可以获取 Class 类的方法,但无法获取私有方法。
而如果使用 Class 对象的 getDeclaredMethods() 方法则可以获取包括私有方法在内的所有方法。
构造器:我们通过 Class 对象的 getConstructors()方法可以获取 Class 类的构造器,但无法获取私有构造器。
而如果使用 Class 对象的 getDeclaredConstructors()方法则可以获取包括私有构造器在内的所有构造器。
1.8.4 反射的优缺点
优点:
- 增加程序的灵活性,避免将程序写死到代码里。
- 代码简洁,提高代码的复用率,外部调用方便
缺点:
- 性能问题:反射包括了一些动态类型,所以JVM无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。我们应该避免在经常被 执行的代码或对性能要求很高的程序中使用反射。
- 使用反射会模糊程序内部逻辑:程序人员希望在源代码中看到程序的逻辑,反射等绕过了源代码的技术,因而会带来维护问题。反射代码比相应的直接代码更复杂。
- 安全限制:使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如Applet,那么这就是个问题了。
- 内部暴露:由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用。
1.9、说一说对注解的理解?
注解也叫元数据。可以声明在包、类、字段、方法、局部变量、方法参数等前面,来对这些元素进行说明、注释等。
Java提供了四种元注解:(所谓元注解就是负责注解其他注解的注解)本质是注解,但是是用来注解其他注解,对其他注解进行说明。
-
@Target:规定注解所修饰的对象范围
ElementType.CONSTRUCTIR; 构造器声明 ElementType.FIELD; 成员变量,对象,属性(包括enum实例) ElementType.LOCAL_VARIABLE; 局部变量声明 ElementType.METHOD ; 方法声明 ElementType.PACKAGE; 包声明 ElementType.PARAMETER;参数声明 ElementType.TYPE; 类、接口(包括注解类型)或enum声明
-
@Retention:规定注解的生命周期
- RetentionPolicy.SOUREC: 在源文件中有效
- RetentionPolicy.CLASS; 在class文件中有效
- RetentionPolicy.RUNTIME;在运行时有效
-
@Inherited: 标记注解,主要说明了一种继承性,意思是子类可以继承父类中的该注解(注意:只有当被贴上@Inherited标签的注解被用在类上的时候子类才能获得这个注解)
-
@Document: 用于描述其它类型的annotation(注解)应该被作为被标注的程序成员的公共API,因此可以被例如javadoc此类的工具文档化。(表明这个注释是由 javadoc记录的,在默认情况下也有类似的记录工具。 如果一个类型声明被注释了文档化,它的注释成为公共API的一部分。)
2、集合


2.1 Java中有哪些集合类?
Java的集合分为单列集合Collection和双列集合Map,其他的接口和实现类都是在这两个接口之下派生的。
Collection接口:单列集合:派生出三个子接口:
- List接口:List接口是有序的(元素存入的顺序和取出的顺序是一致的),可以存储重复元素
- Set接口:Set接口是无序的(元素存入的顺序和取出的顺序不一致),不可以存储重复元素。
- Queue接口:代表先入先出的队列
关于有序无序问题:有序是指:我们按1、3、2、3这样的顺序存储,那么我们取出的顺序也是1、3、2、3无序是指:我们按1、3、2、3这样的顺序存储,我们取出的顺序不知道有可能是2、1、3,也有可能是1、2、3,没有任何规律,不含重复元素 原因:元素存储时,是根据hashCode值去找存储位置,而hashCode值是随机的,所以存储的顺序也是随机的。那么Set是如何保证元素的唯一性?答:在Set的一个实现类HashSet中,元素的唯一性,是靠hashCode()和equals()来判断,使用hashCode()方法计算出当前元素的存储位置,如果当前位置没有元素,直接存储,如果当前位置有元素,使用equals()判断两个元素是否相等,如果相等不用插入,如果不相等,启用哈希冲突的解决办法,将当前元素链接到原来元素的后面。
哈希(散列):是把任意长度的输入通过哈希算法变换成固定长度的输出,该输出就是散列值。hashCode()方法源码:public native int hashCode();调用的native方法,根据新建的对象的内存地址进行哈希得到一个存储的索引。==和equals()的区别? ==运算符: 作用于基本数据类型时,是比较两个数值是否相等; 作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象; equals()方法: 没有重写时,Object默认以 == 来实现,即比较两个对象的内存地址是否相同; 进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相等,否则认为对象不等。为什么要重写equals()和hashCode()? 规定:两个对象相等,则他们的hashCode一定相等;两个对象的hashCode相等,两个对象不一定相等。 equals()方法如果不重写的话,默认判断的两个对象是否是同一个对象,而实际业务中,两个对象的内容相同,我们就可以认为是同一个对象,所以我们通常是需要重写equals()方法的。如果重写equals()不重写hashCode()可能会出现两个对象使用equals()判断相等但是hashCode不相等的情况,与实际不符。所以我们需要同时重写equals()和hashCode()方法。Map接口:Map接口代表具有映射关系的集合。key-value
2.2 List接口
List接口下有三个常用的接口实现类:ArrayList、Vector、LinkedList
List接口定义的方法:
void add(int index, E element) 将指定元素插入此列表中的指定位置(可选操作)。boolean add(E e) 将指定的元素追加到此列表的末尾(可选操作)。 **************boolean contains(Object o) 如果此列表包含指定的元素,则返回 true 。 *******E get(int index) 返回此列表中指定位置的元素。 ***********************boolean isEmpty() 如果此列表不包含任何元素,则返回 true 。 ****************E remove(int index) 删除此列表中指定位置的元素(可选操作)。 ***************boolean remove(Object o) 从该列表中删除指定元素的第一个匹配项(如果存在)(可选操作)。int size() 返回此列表中的元素数。 ***********************2.2.1 ArrayList实现类
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{ private static final long serialVersionUID = 8683452581122892189L; private static final int DEFAULT_CAPACITY = 10;//初始默认容量 private static final Object[] EMPTY_ELEMENTDATA = {}; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; //被transient修饰的变量不参与序列化和反序列化 transient Object[] elementData;//存储结构 private int size;} ArrayList是List接口的实现类,首先满足List的特征,元素有序、可重复,ArrayList是线程不安全的,底层使用数组实现,默认初始容量是10,存满后按照1.5倍扩容,扩容源码如下:
private Object[] grow() { return grow(size + 1);}private Object[] grow(int minCapacity) { return elementData = Arrays.copyOf(elementData, newCapacity(minCapacity));}private int newCapacity(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; //新容量大小=旧容量大小+旧容量右移一位(右移一位相当于除2) int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity <= 0) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) return Math.max(DEFAULT_CAPACITY, minCapacity); if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return minCapacity; } return (newCapacity - MAX_ARRAY_SIZE <= 0) ? newCapacity : hugeCapacity(minCapacity);} ArrayList在对象创建的时候并没有直接创建长度为10的数组,这个数组初始化的过程是在第一次插入元素的时候,(而在JDK1.7中是在对象创建的时候直接创建长度为10 的数组)。源码如下:
private void add(E e, Object[] elementData, int s) { if (s == elementData.length)//判断数组是不是空,如果是空第一次执行扩容,扩容到默认的大小 elementData = grow(); elementData[s] = e; size = s + 1;}ArrayList常用方法:
void add(int index, E element) 将指定元素插入此列表中的指定位置。boolean add(E e) 将指定的元素追加到此列表的末尾。 ***********************boolean contains(Object o) 如果此列表包含指定的元素,则返回 true 。 ******E get(int index) 返回此列表中指定位置的元素。 ***********************boolean isEmpty() 如果此列表不包含任何元素,则返回 true 。 **************int indexOf(Object o) 返回此列表中第一次出现的指定元素的索引,如果此列表不包含该元素,则返回-1。E remove(int index) 删除此列表中指定位置的元素。 ***********************boolean remove(Object o) 从该列表中删除指定元素的第一个匹配项(如果存在)。int size() 返回此列表中的元素数。 ***********************Object[] toArray() 以适当的顺序(从第一个元素到最后一个元素)返回包含此列表中所有元素的数组。ArrayList和数组的区别:
- 数组长度固定,集合长度可变
- 数组可以存基本数据类型,也可以存引用数据类型,而集合只能存引用数据类型。
2.2.2 Vector实现类
public class Vector<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{ protected Object[] elementData; protected int elementCount; protected int capacityIncrement; private static final long serialVersionUID = -2767605614048989439L; public Vector() { this(10);//默认初始容量是10 }} Vector是List接口的实现类,首先满足List的特征,元素有序、可重复,Vector是线程安全的,底层使用数组实现,默认初始容量是10,存满后可以按照自己规定的扩容大小进行扩容默认是2倍扩容,扩容源码如下:
private int newCapacity(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; //新容量大小 = 旧容量大小 + 增长倍数>0? 增长大小:旧容量; 意思是我们可以规定每次扩容多少容量,默认是两倍增长 int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity); if (newCapacity - minCapacity <= 0) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return minCapacity; } return (newCapacity - MAX_ARRAY_SIZE <= 0) ? newCapacity : hugeCapacity(minCapacity);}Vector和ArrayList的区别:
- 两者底层都是使用数组实现的,ArrayList的扩容是1.5倍,Vector的扩容可以自己设置,默认是2倍。
- ArrayList是线程不安全的,Vector是线程安全的,因为Vector的主要方法上使用了synchronized修饰。
- ArrayList效率较高,Vector因线程同步,效率较低,已不推荐使用。如果想获取线程同步的ArrayList可以使用Collections工具类里面的同步方法synchronizedList()方法。
2.2.3 LinkedList实现类
public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, java.io.Serializable{ transient int size = 0; transient Node<E> first; transient Node<E> last; ...... private static class Node<E> { E item; Node<E> next; Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }} LinkedList是List接口的实现类,首先满足List的特征,元素有序、可重复,LinkedList是线程不安全的,底层使用的是双向链表。
LinkedList的常用方法:
void add(int index, E element) 将指定元素插入此列表中的指定位置。 **********boolean add(E e) 将指定的元素追加到此列表的末尾。void addFirst(E e) 在此列表的开头插入指定的元素。 ***********************void addLast(E e) 将指定的元素追加到此列表的末尾。 ***********************boolean contains(Object o) 如果此列表包含指定的元素,则返回 true 。*********E element() 检索但不删除此列表的头部(第一个元素)。 ***********************E get(int index) 返回此列表中指定位置的元素。 ***********************E getFirst() 返回此列表中的第一个元素。E getLast() 返回此列表中的最后一个元素。int indexOf(Object o) 返回此列表中第一次出现的指定元素的索引,如果此列表不包含该元素,则返回-1。int lastIndexOf(Object o) 返回此列表中指定元素最后一次出现的索引,如果此列表不包含该元素,则返回-1。boolean offer(E e) 将指定的元素添加为此列表的尾部(最后一个元素)。 ***************boolean offerFirst(E e) 在此列表的前面插入指定的元素。boolean offerLast(E e) 在此列表的末尾插入指定的元素。E peek() 检索但不删除此列表的头部(第一个元素)。 ***********************E peekFirst() 检索但不删除此列表的第一个元素,如果此列表为空,则返回 null 。E peekLast() 检索但不删除此列表的最后一个元素,如果此列表为空,则返回 null 。E poll() 检索并删除此列表的头部(第一个元素)。 ***********************E pollFirst() 检索并删除此列表的第一个元素,如果此列表为空,则返回 null 。E pollLast() 检索并删除此列表的最后一个元素,如果此列表为空,则返回 null 。E pop() 弹出此列表所代表的堆栈中的元素。 ***********************void push(E e) 将元素推送到此列表所表示的堆栈上。 ***********************E remove() 检索并删除此列表的头部(第一个元素)。 ***********************E remove(int index) 删除此列表中指定位置的元素。 ***********************boolean remove(Object o) 从该列表中删除指定元素的第一个匹配项(如果存在)。 *****E removeFirst() 从此列表中删除并返回第一个元素。 ***********************E removeLast() 从此列表中删除并返回最后一个元素。 **********************int size() 返回此列表中的元素数。ArrayList和LinkedList的区别:
- ArrayList地层使用的是数组,LinkedList底层使用的是双向链表
- 在随机位置的元素的访问上面,ArrayList是优于LinkedList,因为数组存储时连续的,可以直接得到存储位置,而链表存储不连续,需要遍历查找。
- 对于插入和删除,LinkedList要优于ArrayList,因为链表的插入和删除不需要移动元素,只需要修改链表前后的引用,而数组删除需要移动元素,插入需要重新计算容量大小,如果容量不够还需要扩容。
- LinkedList比ArrayList更占内存,因为链表存储前后节点的引用需要占用一部分内存。
2.2.4 有哪些线程安全的List?
- Vector类:不建议使用
- Collections.synchronizedList(List引用)方法:每个方法带有同步锁,不是性能最优
- CopyOnWriteArrayList类:性能最优、对内存压力大、GC更频繁,无法保证数据实时性
CopyOnWriteArrayList是Java并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。正如其名字一样,在写操作时会复制一份新的List,在新的List上完成写操作,然后再将原引用指向新的List。这样就保证了写操作的线程安全。CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高。而写操作的时候,则首先将容器复制一份,然后在新的副本上执行写操作,这个时候写操作是上锁的。结束之后再将原容器的引用指向新容器。注意,在上锁执行写操作的过程中,如果有需要读操作,会作用在原容器上。因此上锁的写操作不会影响到并发访问的读操作。 优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。在遍历传统的List时,若中途有别的线程对其进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器进行遍历时候,也就不会抛ConcurrentModificationException异常了。 缺点:一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC。二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。2.3 Set接口
Set接口下有两个常用的接口实现类:HashSet、TreeSet
Set接口中定义的方法:
boolean add(E e) 如果指定的元素尚不存在,则将其添加到此集合(可选操作)。 ***************boolean contains(Object o) 如果此set包含指定的元素,则返回 true 。 *****************boolean isEmpty() 如果此集合不包含任何元素,则返回 true 。 ***********************int size() 返回此集合中的元素数(基数)。 ***********************boolean remove(Object o) 如果存在,则从该集合中移除指定的元素(可选操作)。 ************2.3.1 HashSet实现类
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable{ static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map;//HashSet的存储是用的HashMap的Key private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); }} HashSet是Set接口的实现类,首先满足Set集合的特征,元素无序、不可重复,HashSet是线程不安全的。HashSet的底层使用的HashMap,使用HashMap的key来存储元素,HashMap默认初始容量是16,扩容容量大小为2的幂(也就是2倍扩容)。
元素唯一性的保证是通过hashCode()和equals()方法实现的,往一个Set集合存储元素的步骤是:先使用hashCode()方法计算该元素的hash值,利用hash值找到元素存储的位置,判断该位置是否为空,如果为空,那么该元素可以直接插入,如果该位置有元素,那么就调用equals()方法判断是否是同一个元素,如果是,那么就是同一个对象,无需存储,如果不是,那么在HashMap中是使用的链地址法解决哈希冲突,将新的元素链接到当前hashCode值的链表后面。
哈希冲突解决办法:1.开放地址法:(1)线性探测:按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上往后加一个单位,直至不发生哈希冲突。(2)再平方探测:按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上先加1的平方个单位,若仍然存在则减1的平方个单位。随之是2的平方,3的平方等等。直至不发生哈希冲突。(3)伪随机探测:按顺序决定值时,如果某数据已经存在,通过随机函数随机生成一个数,在原来值的基础上加上随机数,直至不发生哈希冲突。2.链地址法:对于相同的值,使用链表进行连接。使用数组存储每一个链表。3.再哈希法:对于冲突的哈希值再次进行哈希处理,直至没有哈希冲突。4.建立公共溢出区:建立公共溢出区存储所有哈希冲突的数据。HashSet的常用方法:
boolean add(E e) 如果指定的元素尚不存在,则将其添加到此集合(可选操作)。 ************boolean contains(Object o) 如果此set包含指定的元素,则返回 true 。 **************boolean isEmpty() 如果此集合不包含任何元素,则返回 true 。 ***********************int size() 返回此集合中的元素数(基数)。 ***********************boolean remove(Object o) 如果存在,则从该集合中移除指定的元素(可选操作)。 *********2.3.2 TreeSet实现类
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializable{ private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object();}public interface NavigableSet<K,V> extends SortedSet<K,V> {}public interface SortedMap<K,V> extends Map<K,V> {} TreeSet是Set接口的实现类,首先满足Set集合的特征,元素无序、不可重复,TreeSet是线程不安全的。TreeSet实现了NavigableSet接口,NavigableSet接口继承于SortedSet接口,TreeSet内部实现了比较器,所以存储元素有序。底层基于TreeMap实现, 元素唯一且已经排好序;唯一性同样需要重写hashCode和equals()方法 。
TreeSet的常用方法:
boolean add(E e) 如果指定的元素尚不存在,则将其添加到此集合(可选操作)。 **************boolean contains(Object o) 如果此set包含指定的元素,则返回 true 。 ****************E ceiling(E e) 返回此set中大于或等于给定元素的 null元素,如果没有这样的元素,则 null 。E floor(E e) 返回此set中小于或等于给定元素的最大元素,如果没有这样的元素,则 null 。E higher(E e) 返回此集合中的最小元素严格大于给定元素,如果没有这样的元素,则 null 。E lower(E e) 返回此集合中的最大元素严格小于给定元素,如果没有这样的元素,则 null 。boolean isEmpty() 如果此集合不包含任何元素,则返回 true 。 ***********************E first() 返回此集合中当前的第一个(最低)元素。E last() 返回此集合中当前的最后一个(最高)元素。E pollFirst() 检索并删除第一个(最低)元素,如果此组为空,则返回 null 。E pollLast() 检索并删除最后一个(最高)元素,如果此集合为空,则返回 null 。int size() 返回此集合中的元素数(基数)。 ***********************boolean remove(Object o) 如果存在,则从该集合中移除指定的元素(可选操作)。 *********2.3.3 TreeSet和HashSet的区别
TreeSet和HashSet都是Set接口下的实现类,都是元素无序且唯一,都是线程不安全的,区别是:
- HashSet是基于HashMap实现的(Node数组+链表+红黑树),TreeSet底层基于TreeMap实现的(红黑树)。
- HashSet是不能保证元素的排列顺序,而TreeSet支持自然排序、定值排序。
- HashSet可以存储null值,但是TreeSet不可以存null值。
2.4 Queue接口
Queue接口下有三种常用的接口实现类:ArrayDeque、LinkedList、PriorityQueue
Queue接口中定义的方法:
boolean add(E e) 如果可以在不违反容量限制的情况下立即执行此操作,则将指定的元素插入此队列,成功时返回 true ,如果当前没有空间,则抛出 IllegalStateException 。E element() 检索但不删除此队列的头部。boolean offer(E e) 如果可以在不违反容量限制的情况下立即执行此操作,则将指定的元素插入此队列。E peek() 检索但不删除此队列的头部,如果此队列为空,则返回 null 。E poll() 检索并删除此队列的头部,如果此队列为空,则返回 null 。E remove() 检索并删除此队列的头部。2.4.1 ArrayDeque实现类
public class ArrayDeque<E> extends AbstractCollection<E> implements Deque<E>, Cloneable, Serializable{ transient Object[] elements; transient int head; transient int tail; private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; public ArrayDeque() { elements = new Object[16];//初始化容量是16 } ...... private int newCapacity(int needed, int jump) { final int oldCapacity = elements.length, minCapacity; if ((minCapacity = oldCapacity + needed) - MAX_ARRAY_SIZE > 0) { if (minCapacity < 0) throw new IllegalStateException("Sorry, deque too big"); return Integer.MAX_VALUE; } if (needed > jump) return minCapacity; return (oldCapacity + jump - MAX_ARRAY_SIZE < 0) ? oldCapacity + jump : MAX_ARRAY_SIZE; }} 底层是用循环数组实现的双端队列,初始默认容量是16,扩容是2倍。
普通数组只能快速在末尾添加元素,为了支持FIFO,从数组头快速取出元素,就需要使用循环数组:有队头队尾两个下标:弹出元素时,队头下标递增;加入元素时,如果已到数组空间的末尾,则将元素循环赋值到数组0,同时队尾下标指向0,再插入下一个元素则赋值到数组[1],队尾下标指向1。如果队尾的下标追上队头,说明数组所有空间已用完,进行双倍的数组扩容。
ArrayDeque的常用方法:
boolean add(E e) 在此双端队列的末尾插入指定的元素。 ***********************void addFirst(E e) 在此双端队列的前面插入指定的元素。 ***********************void addLast(E e) 在此双端队列的末尾插入指定的元素。 ***********************boolean contains(Object o) 如果此双端队列包含指定的元素,则返回 true 。 ************E element() 检索但不删除此双端队列表示的队列的头部。 ***********************E getFirst() 检索但不删除此双端队列的第一个元素。E getLast() 检索但不删除此双端队列的最后一个元素。boolean isEmpty() 如果此双端队列不包含任何元素,则返回 true 。 ***********************boolean offer(E e) 在此双端队列的末尾插入指定的元素。 ***********************boolean offerFirst(E e) 在此双端队列的前面插入指定的元素。boolean offerLast(E e) 在此双端队列的末尾插入指定的元素。E peek() 检索但不删除此双端队列表示的队列的头部,如果此双端队列为空,则返回 null 。 *******E poll() 检索并删除此双端队列表示的队列的头部(换句话说,此双端队列的第一个元素),如果此双端队列为空,则返回 null 。 ******E pop() 从此双端队列表示的堆栈中弹出一个元素。 ***********************void push(E e) 将元素推送到此双端队列表示的堆栈上。 ***********************E remove() 检索并删除此双端队列表示的队列的头部。 **********************E removeFirst() 检索并删除此双端队列的第一个元素。 ***********************E removeLast() 检索并删除此双端队列的最后一个元素。 ***********************int size() 返回此双端队列中的元素数。 ***********************2.4.2 LinkedList实现类
LinkedList是Queue接口的实现类,LinkedList是线程不安全的,底层使用的是双向链表,类的具体定义见2.2.3
2.4.3 PriorityQueue实现类
public class PriorityQueue<E> extends AbstractQueue<E> implements java.io.Serializable { private static final long serialVersionUID = -7720805057305804111L; private static final int DEFAULT_INITIAL_CAPACITY = 11; transient Object[] queue; // non-private to simplify nested class access int size; private final Comparator<? super E> comparator; transient int modCount; // non-private to simplify nested class access public PriorityQueue() { this(DEFAULT_INITIAL_CAPACITY, null); } ...... private void grow(int minCapacity) { int oldCapacity = queue.length; // Double size if small; else grow by 50% int newCapacity = oldCapacity + ((oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1)); // overflow-conscious code if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); queue = Arrays.copyOf(queue, newCapacity); }} PriorityQueue是Queue接口的实现类,PriorityQueue是线程不安全的,底层使用的是数组实现的二叉小根堆,可以保证每次取出的值都是最小的,我们也可以在构造时传入比较器,改变排序方式。数组的初始容量是11,扩容的话,如果当前容量小于64,则按两倍扩容,如果大于64则按1.5倍扩容。
PriorityQueue常用方法:
boolean add(E e) 将指定的元素插入此优先级队列。E element() 检索但不删除此队列的头部。boolean contains(Object o) 如果此队列包含指定的元素,则返回 true 。boolean offer(E e) 将指定的元素插入此优先级队列。E poll() 检索并删除此队列的头部,如果此队列为空,则返回 null 。boolean remove(Object o) 从此队列中删除指定元素的单个实例(如果存在)。2.5 Map接口
Map接口下有三种常用的接口实现类:HashMap、Hashtable、TreeMap
Map接口中定义的方法:
boolean containsKey(Object key) 如果此映射包含指定键的映射,则返回 true 。boolean containsValue(Object value) 如果此映射将一个或多个键映射到指定值,则返回 true 。Set<Map.Entry<K,V>> entrySet() 返回此映射中包含的映射的Set视图。V get(Object key) 返回指定键映射到的值,如果此映射不包含键的映射,则返回 null。default V getOrDefault(Object key, V defaultValue) 返回指定键映射到的值,如果此映射不包含键的映射,则返回defaultValue.boolean isEmpty() 如果此映射不包含键 - 值映射,则返回 true 。Set<K> keySet() 返回此映射中包含的键的Set视图。V put(K key, V value) 将指定的值与此映射中的指定键相关联(可选操作)。V remove(Object key) 如果存在,则从该映射中移除键的映射(可选操作)。default boolean remove(Object key, Object value) 仅当指定键当前映射到指定值时才删除该条目的条目。default V replace(K key, V value) 仅当指定键当前映射到某个值时,才替换该条目的条目。default boolean replace(K key, V oldValue, V newValue) 仅当前映射到指定值时,才替换指定键的条目。int size() 返回此映射中键 - 值映射的数量。2.5.1 HashMap实现类
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { private static final long serialVersionUID = 362498820763181265L; static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 初始容量 static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认装载因子 static final int TREEIFY_THRESHOLD = 8; //链表树化的链表长度 static final int UNTREEIFY_THRESHOLD = 6; //树转链表的树的节点数 static final int MIN_TREEIFY_CAPACITY = 64;//链表转树时,数组最低长度 static class Node<K,V> implements Map.Entry<K,V> {//Node节点结构,我们的数据都存储在Node节点中 final int hash; final K key; V value; Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } transient Node<K,V>[] table;//Node数组,用于存储数据 transient Set<Map.Entry<K,V>> entrySet; transient int size; //节点数目 transient int modCount; int threshold; //扩容门限=装载因子*容量大小 final float loadFactor;//装载因子 //构造方法 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }}在jdk1.7中
HashMap底层采用的是Entry数组+链表的实现,即使用链表来处理哈希冲突,相同hashCode值的的节点都存在一个链表里,但是当某一个位置的冲突较多时,依次通过key值查找效率依然很低。

在jdk1.8中的优化
HashMap底层采用的是Node数组+链表+红黑树的实现,同样使用链地址法来 处理哈希冲突,只不过当链表长度达到8时,就会将链表转换成红黑树,大大减少了查找时间。当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中。
**总结:**HashMap底层采用的是Node数组+链表+红黑树的实现,可以存储null。初始容量是16,加载因子是0.75,当存储的容量达到0.75时扩容,扩容是两倍。HashMap是线程不安全的。
HashMap的扩容机制:
-
数组的初始容量为16,而容量是以2的次方扩充的,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算(据说提升了5~8倍)。
-
数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75倍时,就会扩充数组。这个0.75就是默认的负载因子,可由构造器传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
-
为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能。
-
对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。
HashMap的put(key,value)和get(key)操作:
get(key)操作:调用hashCode()根据key计算出hash值,从而找到对象存储位置,然后调用equals()方法在该位置上的链表一直判断,直到找到key相同的对象,返回value。
put(key,value)操作:先判断Node数组是否为null,如果为null则先扩容,扩容后调用hashCode()根据key计算出hash值,从而找到对象存储的位置,判断该位置是否为null,如果为null,直接插入,如果不为null,调用equals()挨个判断该hash值链接的链表或者红黑树,如果有key相同的则直接覆盖,没有相同的则插入链表或者红黑树,如果插入节点后链表长度达到8,那么要对链表进行树化操作。
树化操作:链表节点数达到8时,会调用树化的方法,先判断Node数组的长度有没有达到64,如果没有达到64,那么执行扩容,放弃树化,如果Node数组的长度达到64,那么执行树化操作,将链表转化成红黑树。
树化操作代码如下:
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); }}hashmap为什么在链表长度为8的时候变为红黑树?为什么是8?
源码中的注释写的很清楚,因为树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。也就是说,节点少的时候,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占空间比较大,综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。
源码上说,为了配合使用分布良好的hashCode,树节点很少使用。并且在理想状态下,受随机分布的hashCode影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是8的概率已经接近千分之一,而且此时链表的性能已经很差了。所以在这种比较罕见和极端的情况下,才会把链表转变为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了挽回性能,权衡之下,才使用红黑树,提高性能。也就是大部分情况下,hashmap还是使用的链表,如果是理想的均匀分布,节点数不到8,hashmap就自动扩容了。
HashMap的线程安全问题:HashMap是线程不安全的,在多线程并发访问的时候会出现死循环、数据丢失、数据覆盖的问题。
在jdk1.7中,在多线程访问的情况下,并发执行put操作时,如果这时候刚好HashMap需要扩容,两个线程同时对HashMap进行扩容,在内存中会存在两个HashMap的Node数组,如果一个线程对链表节点的处理结束但还没有重新设置HashMap的引用,另一个线程又对链表就行设置就会出现死循环的现象,这是由于链表插入采用的是头插法,会形成环,
在jdk1.8中使用的尾插法,解决了这个头插法产生的弊端,但是如果多线程访问需要对链表转换成树或者对树进行操作可能出现死循环的问题。
数据覆盖:假如说两个线程A、B都在进行put操作,并且由hash值算出来的数据下标都一样,这样就出现了碰撞,假设线程A在执行到的那一行由于时间片用完了,线程B能够正常的放进去(因为A还没有将数据放进去),但是当轮到线程A执行的时候,由于之前已经判断过了,此时会直接覆盖掉线程B存放的数据,导致数据覆盖还有一个地方就是++size这行代码,由于它不是原子操作,当A+1操作后还未写会主存,由于时间片用完,线程B拿到的还是原来的size,也就是说A、B都+1了,加了两次,实际上只有1次,从而造成线程的不安全。
HashMap为什么采用红黑树而不是AVL树、B树或者B+树?
平衡二叉树和红黑树的查找时间相差不多,时间复杂度都为O(lgn),但是AVL树更加强调平衡,为了达到平衡多了很多树的调整操作。但实际上AVL树在查找密集型任务上更快:利用更好的平衡,树遍历平均更短。另一方面,插入和删除方面,AVL树速度较慢:需要更高的旋转次数才能在修改时正确地重新平衡数据结构。红黑树和AVL都是O(log n)查找,但平衡AVL树可能需要O(log n)旋转,而红黑树将需要最多两次旋转使其达到平衡(尽管可能需要检查O(log n)节点以确定旋转的位置)。旋转本身是O(1)操作,因为你只是移动指针。
B/B+树多用于外存上时,B/B+也被成为一个磁盘友好的数据结构。HashMap本来是Node数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。如果用B/B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这个时候遍历效率就退化成了链表。
HashMap的常用方法:
boolean containsKey(Object key) 如果此映射包含指定键的映射,则返回 true 。boolean containsValue(Object value) 如果此映射将一个或多个键映射到指定值,则返回 true 。Set<Map.Entry<K,V>> entrySet() 返回此映射中包含的映射的Set视图。V get(Object key) 返回指定键映射到的值,如果此映射不包含键的映射,则返回 null 。boolean isEmpty() 如果此映射不包含键 - 值映射,则返回 true 。Set<K> keySet() 返回此映射中包含的键的Set视图。V put(K key, V value) 将指定的值与此映射中的指定键相关联。V remove(Object key) 从此映射中删除指定键的映射(如果存在)。int size() 返回此映射中键 - 值映射的数量。2.5.2 Hashtable实现类
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable { private transient Entry<?,?>[] table; private transient int count; private int threshold; private float loadFactor; private transient int modCount = 0; private static final long serialVersionUID = 1421746759512286392L; public Hashtable() { this(11, 0.75f); }} Hashtable底层是使用的Entry数组+链表的实现,初始容量是11,默认加载因子是0.75,扩容是原来容量的2倍加1(新容量=旧容量 * 2 + 1)。Hashtable是线程安全的,因为在其主要的方法上都加了synchronized关键字进行同步。
Hashtable和HashMap的区别:*
-
Hashtable底层是使用的Entry数据+链表的实现,初始容量是11,扩容是原来容量的2倍加1,HashMap底层采用的是Node数组+链表+红黑树的实现,初始容量是16,扩容是两倍。
-
Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点。
-
Hashtable不允许使用null作为key和value,如果试图把null值放进Hashtable中,将会引发空指针异常,但HashMap可以使用null作为key或value。
2.5.3 TreeMap实现类
public class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, java.io.Serializable{ private final Comparator<? super K> comparator; private transient Entry<K,V> root; private transient int size = 0; private transient int modCount = 0;} TreeMap底层是使用的红黑树实现的,线程不安全,实现SortedMap,支持遍历时按元素的大小有序遍历。该集合最重要的特点就是可排序,该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator比较器进行排序,具体取决于使用的构造方法。
2.5.4 如何得到一个线程安全的Map?
- 使用Collections工具类,将线程不安全的Map包装成线程安全的Map; Collections.synchronizedMap(map);
- 使用java.util.concurrent(JUC)包下的Map,如ConcurrentHashMap;
- 不建议使用Hashtable,虽然Hashtable是线程安全的,但是性能较差。
2.5.5 JUC下的ConcurrentHashMap介绍
JDK 1.7中的实现:
在 jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组结构构成,采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在 ConcurrentHashMap 中扮演锁的角色,HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个Segment 里包含一个 HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。

JDK 1.8中的实现:
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用CAS+synchronized+volatile来操作,整个看起来就像是优化过且线程安全的HashMap

ConcurrentHashMap的put()和get()操作:
get()操作: ConcurrentHashMap 同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。如果头节点的 hash 值小于 0,说明正在扩容或者该节点是红黑树。为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS;}**put()操作:**ConcurrentHashMap 是通过Key的哈希值与数组长度取模确定该Key在数组中的索引,第一步先判断数组是不是空,如果为空先进行数组初始化过程,然后根据索引查看当前位置是否为空,如果为空用使用 CAS 操作将这个新值放入这个位置,如果不为空,首先判断该位置的首个元素的hash 值如果等于 MOVED ,说明在扩容,帮助数据迁移 helpTransfer,否则获取synchronized 监视器锁,然后在该索引下的链表遍历,如果有key相等直接覆盖,如果都不相等,在链表的结尾添加该对象,然后根据链表的长度判断链表是否需要树化。
static final int MOVED = -1; // hash for forwarding nodes 扩容,正在转移结点static final int TREEBIN = -2; // hash for roots of trees 是红黑树节点static final int RESERVED = -3; // hash for transient reservationsstatic final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash 用于计算最终hash值
HashMap和ConcurrentHashMap的区别:
HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。
Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。
ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
3、多线程
进程:是操作系统资源分配的基本单位,比如内存、打开文件、网络IO,分配了独立的内存空间线程:是操作系统资源调度的基本单位,cpu分配的基本单位纤程:(协程)是用户态的线程,是线程中的线程,切换和调度不需要经过OS(操作系统)。;轻量级的线程 - 线程串行:一个一个排队执行并行:是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。并发:关键是你有处理多个任务的能力,不一定要同时。
线程和进程的区别总结:1、调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位;2、并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可并发执行;3、拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。进程所维护的是程序所包含的资源(静态资源),线程所维护的运行相关的资源(动态资源)。4、系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。但是进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个进程死掉就等于所有的线程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
纤程的优势: 1.占有的资源少,为什么说他占有资源少呢?举例:操作系统要启一个线程前后要为这个线程配的内存数据大概有1M,而纤程大概是4K 2.由于纤程非常的轻量级,所以切换比较简单 3.可以同时被启动很多个(10万个都没问题)
进程间的通信方式: 1.管道 2.信号量 3.共享内存 4.套接字socket线程间的通信方式: 1.消息队列 2.使用信号量 3.互斥 4.非阻塞CAS3.1 怎么创建一个线程?
3.1.1 继承Thread
通过Thread继承类来创建并启动线程的步骤:
- 定义Thread类的子类,重写run()方法,(run()方法里面试线程执行的任务)run()方法无返回值。
- 创建Thread子类的实例对象
- 调用Thread子类实例的start()方法启动线程。
class MyThread extends Thread{ //1 @Override public void run(){ //线程执行体 }}//使用MyThreadThread t = new MyThread(); //2t.start(); //33.1.2 实现Runnable接口
- 定义一个Runnable接口的实现类,重写run()方法。run()方法无返回值。
- 创建一个Runnable接口实现类的实例对象
- 将Runnable接口实现类的实例对象作为Target用来创建一个Thread对象
- 调用Thread对象的start()方法启动线程。
class MyRunnable implements Runnable{ //1 @Override public void run() { //线程执行体 }}//使用MyRunnableMyRunnable myRunnable = new MyRunable(); //2Thread t = new Thread(myRunnable); //3t.start(); //43.1.3 实现Callable接口
- 定义一个Callable接口的实现类,重写cal()方法。其中call()方法是有返回值的。
- 创建Callable接口实现类的实例对象
- 将Callable接口实现类的实例对象作为参数,创建一个Futuretask实例对象,FutureTask封装了call()方法的返回值。
- 将FutureTask的实例对象作为Target用来创建一个Thread对象
- 调用Thread对象的start()方法启动线程。
- 调用FutureTask实例对象的get()方法获取子线程执行结束的返回值
class MyCallable implements Callable{ //1 @Override public Object call() throws Exception { //线程执行体 return 线程执行完需要返回的值; }}//使用MyCallableMyCallable myCallable = new MyCallable() //2FutureTask futureTask = new FutureTask(myCallable); //3Thread t = new Thread(futureTask); //4t.start(); //5线程执行完返回值=futureTask.get();//6.执行get()会抛出异常InterruptedException,ExecutionException,需要使用try catch处理3.1.4 使用线程池创建线程
从Java 5开始,Java内建支持线程池。Java 5新增了一个Executors工厂类来产生线程池,该工厂类包含如下几个静态工厂方法来创建线程池。创建出来的线程池,都是通过ThreadPoolExecutor类来实现的。
newCachedThreadPool():创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程将会被缓存在线程池中。
newFixedThreadPool(int nThreads):创建一个可重用的、具有固定线程数的线程池。
newSingleThreadExecutor():创建一个只有单线程的线程池,它相当于调用newFixedThreadPool()方法时传入参数为1。
newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程是空闲的也被保存在线程池内。
newSingleThreadScheduledExecutor():创建只有一个线程的线程池,它可以在指定延迟后执行线程任务。
ExecutorService newWorkStealingPool(int parallelism):创建持有足够的线程的线程池来支持给定的并行级别,该方法还会使用多个队列来减少竞争。
ExecutorService newWorkStealingPool():该方法是前一个方法的简化版本。如果当前机器有4个CPU,则目标并行级别被设置为4,也就是相当于为前一个方法传入4作为参数。
//使用ThreadPoolExecutorThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5,5,1L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(100),//队列 new ThreadPoolExecutor.CallerRunsPolicy(),//线程创建工厂 new AbortPolicy();//拒绝策略 );//创建一个线程池for (int i = 0; i < 20; i++) { Runnable worker = new MyRunnable(""+i); threadPoolExecutor.execute(worker);}threadPoolExecutor.shutdown();while(!threadPoolExecutor.isTerminated()){}System.out.println("Finished all threads");
//使用Executors工厂类中的方法来创建线程,Executors内部其实也是调用的ThreadPoolExecutorExecutorService executorService = Executors.newFixedThreadPool(5);for (int i = 0; i < 20; i++) { Runnable worker = new MyRunnable(""+i); executorService.execute(worker);}executorService.shutdown();**线程池的参数:**ThreadPoolExecutor括号中的参数含义
-
corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。
-
maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。
-
keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
-
workQueue(队列):用于传输和保存等待执行任务的阻塞队列。
-
threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用newThread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
-
handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略。
为什么使用线程池?
系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。
线程池的工作流程?

-
判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。
-
判断任务队列是否已满,没满则将新提交的任务添加在工作队列。
-
判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务,已满则执行饱和(拒绝)策略。
线程池的拒绝策略:
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:
-
AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
-
DiscardPolicy:也是丢弃任务,但是不抛出异常。
-
DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)。
-
CallerRunsPolicy:由调用线程处理该任务。
3.2 Thread中的常用方法
构造方法:
Thread()//无参构造
Thread(String name)//传入线程名
Thread(Runnable target)//使用Runnable接口构造
Thread(Runnable target, String name)//使用Runnable接口和传入一个线程名
静态方法:
currentThread():返回当前正在执行的线程;
interrupted():返回当前执行的线程是否已经被中断;
sleep(long millis):使当前执行的线程睡眠多少毫秒数;
yield():使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行;回到就绪状态
实例方法:
getId():返回该线程的id;
getName():返回该线程的名字;
getPriority():返回该线程的优先级;
interrupt():使该线程中断;
isInterrupted():返回该线程是否被中断;
isAlive():返回该线程是否处于活动状态;
isDaemon():返回该线程是否是守护线程;
setDaemon(boolean on):将该线程标记为守护线程或用户线程,如果不标记默认是非守护线程;
setName(String name):设置该线程的名字;
setPriority(int newPriority):改变该线程的优先级;
join():等待该线程终止;
join(long millis):等待该线程终止,至多等待多少毫秒数。
3.3 线程的生命周期
在线程的生命周期中,它要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。
当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务。当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:
1. 线程**调用sleep()**方法主动放弃所占用的处理器资源。
2. 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
3. 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。
4. 线程在等待某个通知(notify)。
5. 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。
针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态:
1. 调用sleep()方法的线程经过了指定时间。
2. 线程调用的阻塞式IO方法已经返回。
3. 线程成功地获得了试图取得的同步监视器。
4. 线程正在等待某个通知时,其他线程发出了一个通知。
5. 处于挂起状态的线程被调用了resume()恢复方法。
线程会以如下三种方式结束,结束后就处于死亡状态:
1. run()或call()方法执行完成,线程正常结束。
2. 线程抛出一个未捕获的Exception或Error。
3. 直接调用该线程的stop()方法来结束该线程,该方法容易导致死锁,通常不推荐使用。
3.4 线程同步
-
使用synchronized
-
使用Lock
-
使用volatile
-
使用ThreadLocal
-
使用原子变量
-
final修饰的变量:不可变的,即使共享也不会产生线程安全问题,因为不可修改。
3.4.1 synchronized
synchronized是什么?
synchronized 是Java的关键字,其作用是对同步的代码加锁,使得在同一时间只能有一个线程进入代码,从而达到同步的目的。synchronized 可以同步方法,也可以同步代码块。
使用synchronized 同步代码块:
synchronized 同步代码块的时候底层是通过monitorenter、monitorexit指令来实现的。因为每个对象都是一个monitor(监视器锁),当这个锁被占用的时候,整个对象就处于锁定状态,当一个线程执行monitorenter指令尝试去获取对象锁的时候,如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
使用synchronized 同步方法:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
synchronized可以修饰静态方法,但不能修饰静态代码块。
当修饰静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于方法区,因此静态方法锁相当于该类的一个全局锁。
线程之间的通信:wait()、notify()、notifyAll()
如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。
wait()方法可以让当前线程释放对象锁并进入阻塞状态。
notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
注:每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。synchronized锁升级:
锁的状态有四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。如下图所示:

-
无锁
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
-
偏向锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
-
轻量级锁
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。
-
重量级锁
忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
扩展:
synchronized 用的锁是存在Java对象头里的,那么什么是对象头呢?我们以 Hotspot 虚拟机为例进行说明,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 Klass Pointer(类型指针)。
- Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。- Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
一个对象在内存中的存储布局可以划分为三部分:对象头、实例数据、对齐填充 对象头:包括MarkWord和类型指针 实例数据:对象真正存储的有效信息。无论是从父类继承下来的还是子类中定义的字段都必须定义 对齐填充:不是必然存在,仅是占位符。(由于HotSpot虚拟机的自动管理系统要求起始地址必须是8字节的整数倍,所以任何对象的大小都必须是8的整数倍) 那么,synchronized 具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?
在64位的虚拟机中:

3.4.2 Lock锁
synchronized的缺陷
一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;2)线程执行发生异常,此时JVM会让线程自动释放锁。
缺陷情况:
1. 某个已经获得锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他想要获取锁的线程便只能一直阻塞,直到获得锁的线程释放锁了才能进入到同步代码块。
2. 当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。如果多个线程都只是进行读操作,当一个线程在进行读操作时,其他线程只能等待无法进行读操作。
Lock是一个接口,里面定义了获取锁,释放锁等相关的抽象方法。
public interface Lock {2 void lock();//用来获取锁的3 void lockInterruptibly() throws InterruptedException;4 boolean tryLock();5 boolean tryLock(long time, TimeUnit unit) throws InterruptedException;6 void unlock();7 Condition newCondition();8 }- lock()方法是用来获取锁的,是我们平时使用最多的一个方法,如果锁已被其他线程获取,则进行等待。我们之前说过了,使用lock获取锁的时候,必须手动释放锁,并且在发生异常的时候,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。
- tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立 即返回。在拿不到锁时不会一直在那等待。
- tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在**拿不到锁时会等待一定的时间,**在时间期限之内如果还拿不到锁,就返回false。 如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
- lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时通 过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。
Lock只负责获取锁和释放锁,如果需要实现类似wait()/notify()这种等待/通知的机制,还需要借助Condition接口。
Condition接口提供了最基本的实现等待/通知机制的API,但是它比wait()/notify()更强大,这里先介绍一些它最基本的API。await():让当前线程进入WAITING状态,同时释放锁,类似wait()的作用。signal():通知某个处于WAITING状态的线程,可以继续获取锁执行。类似notify()的作用。signalAll():通知所有处于WAITING状态的线程,开始争抢锁然后执行。类似notifyAll()的作用。 从ReentrantLock.newCondition()方法的实现可以看出来,其实这个Condition是每次都new出来的,所以是一个Lock可以存在多个Condition的。类似于一个说存在多个条件,各个条件管控自己的await()和signal()状态,相互之间并不影响。
ReentrantLock:可重入锁:Lock的实现类
何为可重入锁?
当一个线程获得了当前实例的锁,并进入方法A,这个线程在没有释放这把锁的时候,可以再次进入方法A。
public class ReentrantLock implements Lock, java.io.Serializable { private static final long serialVersionUID = 7373984872572414699L; /** Synchronizer providing all implementation mechanics */ private final Sync sync; abstract static class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = -5179523762034025860L; .... } static final class NonfairSync extends Sync { private static final long serialVersionUID = 7316153563782823691L; protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } } static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; ... }} ReentrantLock 是基于 AQS 实现的, AQS 即 AbstractQueuedSynchronizer (抽象队列同步器)的缩写,这个内部提供了一个FIFO队列。该队列是一个双向链表,就是用来实现线程的并发访问控制。
AQS使用一个整型的volatile变量state来维护同步状态,这个volatile变量是实现ReentrantLock的关键
state初始化为0,表示未锁定状态.A线程lock时,会调用tryAcquire独占该锁并将state+1。此后,其他线程再tryAcquire时就会失败,直到A线程unlock到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。AQS提供了两种功能:独占和共享
独占:就是在lock.lock()之后的代码在同一时刻只能有一个线程来执行,其余的线程将会被阻塞,直到该线程执行了lock.unlock()。
共享:就是读锁与读锁可以共享,读锁与写锁不可以共享(排他),写锁与写锁不可以共享(排他)
**对于ReentrantLock,有两种获取锁的模式:公平锁和非公平锁。**构造方法(不带参数 和带参数 true: 公平锁; false: 非公平锁)
ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取
公平锁:FairSync:公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有,将当前线程加入到等待队列。
非公平锁:NonfairSync:与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁,被加入了等待队列后则跟公平锁没有区别。
总结:从这里我们也能看出公平锁和非公平锁的区别:公平锁的新入线程不能直接获取锁,必须去排队(除非没有任何竞争发生) 。 而非公平锁新入的线程则可以先尝试获取锁,如果失败了再排队。
公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到 state == 0 时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS的结合是并发抢占的关键。
说一说synchronized与Lock的区别
-
synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
-
synchronized可以用在代码块上、方法上;Lock只能写在代码里。
-
synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
-
synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
-
synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
-
synchronized锁可重入、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。
悲观锁与乐观锁:
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java中悲观锁是通过synchronized关键字或Lock接口来实现的。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在JDK1.5 中新增 java.util.concurrent (JUC)就是建立在CAS之上的。相对于对于synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以JUC在性能上有了很大的提升。
CAS操作:CAS,compare and swap的缩写,中文翻译成比较并交换。 CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。” 通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。 类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。3.4.3 volatile关键字
首先描述一下Java内存模型JMM:JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

volatile关键字修饰的变量具有以下两个特征:
-
线程可见性:当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,这个写会操作会导致其他线程中的volatile变量缓存无效。
-
禁止指令重排:使用volatile关键字修饰共享变量可以禁止指令重排序,volatile禁止指令重排序有一些规则:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。
底层实现原理:
在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障。
3.4.4 ThreadLocal
博客详解:https://www.cnblogs.com/micrari/p/6790229.html
使用**ThreadLocal解决线程局部变量统一定义问题,**多线程数据不能共享。

ThreadLocal顾名思义是线程私有的局部变量存储容器,可以理解成每个线程都有自己专属的存储容器,它用来存储线程私有变量,其实它只是一个外壳,内部真正存取是一个Map。每个线程可以通过set() 和 get() 存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可访问的,线程被终止后,它的所有实例将被垃圾收集。总之记住一句话:ThreadLocal存储的变量属于当前线程。
实现原理:
Thread类中有个变量threadLocals,它的类型为ThreadLocal中的一个内部类ThreadLocalMap,这个类没有实现map接口,就是一个普通的Java类,但是实现的类似map的功能。每个线程都有自己的一个ThreadLocalMap,ThreadLocalMap是一个数组的数据结构存储数据,每个元素是一个Entry,entry的key是ThreadLocal的弱引用,也就是当前变量的副本,value就是set的值。
ThreadLocal.ThreadLocalMap threadLocals = null;static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> {//弱引用,垃圾回收器扫描到就会被回收 Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } private static final int INITIAL_CAPACITY = 16;//Entry数组初始容量为16,扩容是2的幂 private Entry[] table; private int size = 0; private int threshold; // Default to 0 private void setThreshold(int len) { threshold = len * 2 / 3; } private static int nextIndex(int i, int len) {//下一个索引 return ((i + 1 < len) ? i + 1 : 0); } private static int prevIndex(int i, int len) {//上一个索引 return ((i - 1 >= 0) ? i - 1 : len - 1); } ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {//构造函数 table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); }} 从源码中我们可以看出Entry数组初始容量为16,扩容门限是容量的2/3,达到门限时扩容,Entry数组可以获得下一个索引和上一个索引,在逻辑上是一个循环数组,在ThreadLocalMap中youint i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);ThreadLocal类中有一个被final修饰的类型为int的threadLocalHashCode,它在该ThreadLocal被构造的时候就会生成,相当于一个ThreadLocal的ID,而它的值来源于
/* * 生成hash code间隙为这个魔数,可以让生成出来的值或者说ThreadLocal的ID较为均匀地分布在2的幂大小的数组中。 */private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT);} 可以说在ThreadLocalMap中,形如key.threadLocalHashCode & (table.length - 1)(其中key为一个ThreadLocal实例)这样的代码片段实质上就是在求一个ThreadLocal实例的哈希值,只是在源码实现中没有将其抽为一个公用函数。
private void rehash() { expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis if (size >= threshold - threshold / 4) resize();}private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0;
for (Entry e : oldTab) { if (e != null) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } }
setThreshold(newLen); size = count; table = newTab;} 数组长度达到总长度的3/4时,扩容,扩容倍数为两倍。
为什么Entry的key是ThreadLocal的弱引用?
因为如果这里使用普通的key-value形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收,而程序本身也无法判断是否可以清理节点。弱引用是Java中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次GC。当某个ThreadLocal已经没有强引用可达,则随着它被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,这为ThreadLocalMap本身的垃圾清理提供了便利。
ThreadLocal中的内存泄露问题:
ThreadLocalMap中的Entry的key使用的是ThreadLocal对象的弱引用,在没有其他地方对ThreadLoca依赖, ThreadLocalMap中的ThreadLocal对象就会被回收掉,但是对应的value不会被回收,这个时候Map中就可能存在 key为null但是value不为null的项,这需要实际的时候使用完毕及时调用remove方法避免内存泄漏。
java的四种引用类型:强软弱虚a. 强引用(Strongly Reference):强引用是最传统的引用的定义,是指在程序代码中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况,只要强引用关系还存在,垃圾回收器就永远不会回收掉被引用的对象。b. 软引用(Soft Reference):软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出你内存溢出异常。SoftReference类实现软引用。c. 弱引用(Weak Reference):弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类实现弱引用d. 虚引用(Phantom Reference):虚引用也称为幽灵引用或者幻影引用,他是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类实现虚引用。ThreadLocal的哈希冲突采用线性探测法解决
3.4.5 原子变量
原子变量最主要的一个特点就是所有的操作都是原子的,synchronized关键字也可以做到对变量的原子操作。只是synchronized的成本相对较高,需要获取锁对象,释放锁对象,如果不能获取到锁,还需要阻塞在阻塞队列上进行等待。而如果单单只是为了解决对变量的原子操作,建议使用原子变量。
在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
JUC包下含有的:
- 原子更新:Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环 境下,无锁的进行原子操作。在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新 数组,原子更新引用和原子更新字段。
- 锁和条件变量:java.util.concurrent.locks包下包含了同步器的框架 AbstractQueuedSynchronizer,基于AQS构建的Lock以及与Lock配合可以实现等待/通知模式的Condition。JUC 下的大多数工具类用到了Lock和Condition来实现并发。
- 线程池:涉及到的类比如:Executor、Executors、ThreadPoolExector、 AbstractExecutorService、Future、Callable、ScheduledThreadPoolExecutor等等。
- 阻塞队列:涉及到的类比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、LinkedBlockingDeque等等。
- 并发容器:涉及到的类比如:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、CopyOnWriteArraySet等等。
- 同步器:剩下的是一些在并发编程中时常会用到的工具类,主要用来协助线程同步。比如:CountDownLatch、CyclicBarrier、Exchanger、Semaphore、FutureTask等等。
4、JVM(java虚拟机)
java程序的执行过程:
概括来说,写好的 Java 源代码文件经过 Java 编译器编译成字节码文件后,通过类加载器加载到内存中,才能被实例化,然后到 Java 虚拟机中解释执行,最后通过操作系统操作 CPU 执行获取结果。
普通对象的实例化过程:(不包括数组和Class对象 )
- 普通对象的创建过程(不包括数组和Class对象):当java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么先执行官相应的类加载过程。
- 类加载检查通过之后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从java堆中划分出来。(分配空间使用指针碰撞,但是指针碰撞存在线程安全问题,解决线程安全问题有两种方案:一是对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即为每个线程在java堆中预先分配内存,称为本地线程分配缓冲(TLAB ), 哪个线程要分配内存,就是在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。)
- 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。
- java虚拟机还要对对象头进行设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。
- 上面三步完成从虚拟机角度,一个新的对象已经产生,但是从java程序来看,对象创建才刚刚开始,构造函数还没有执行,即Class文件中的**
()方法还没有执行**,所有字段都是默认的0值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。
数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但是数组类里面的元素可能需要使用类加载器加载。
**对象在内存中的布局:**见3.4.1 锁升级的扩展部分
4.1 JVM的内存结构
JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。

JVM 是执行 Java 程序的虚拟计算机系统,那我们来看看执行过程:首先需要准备好编译好的 Java 字节码文件(即class文件,是一串二进制字节流),计算机要运行程序需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区),但是字节码文件是JVM定义的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行,这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地图制作等),这就用到了本地 Native 接口(本地库接口)。
ClassLoader:负责加载字节码文件即 class 文件,class 文件在文件开头有特定的文件标示,并且ClassLoader 只负责class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。
Runtime Data Area:是存放数据的,分为五部分:Stack(虚拟机栈),Heap(堆),MethodArea(方法区),PC Register(程序计数器),Native Method Stack(本地方法栈)。几乎所有的关于 Java 内存方面的问题,都是集中在这块。
Execution Engine:执行引擎,也叫 Interpreter。Class 文件被加载后,会把指令和数据信息放入内存中,Execution Engine 则负责把这些命令解释给操作系统,即将 JVM 指令集翻译为操作系统指令集。
Native Interface:负责调用本地接口的。他的作用是调用不同语言的接口给 JAVA 用,他会在Native Method Stack 中记录对应的本地方法,然后调用该方法时就通过 Execution Engine 加载对应的本地 lib。原本多用于一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。
4.1.1 运行时数据区
程序计数器:线程私有
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈:线程私有
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
本地方法栈:线程私有
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
堆:线程共享
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数**-Xmx和-Xms**设定)。
方法区:线程共享
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
运行时常量池:线程共享
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
直接内存:
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
相关问题:
局部变量存储在哪:虚拟机栈中
类存放在哪:方法区中
元空间在本地内存
4.2 三层类加载器和双亲委派机制
4.2.1 类的生命周期
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示。

其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载必须按照这种顺序按部就班的开始,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称动态绑定或晚期绑定)。
一、加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:
-
通过一个类的全限定名来获取定义此类的二进制字节流。
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
-
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
二、验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
三、准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。在方法区中分配。
四、解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
五、初始化
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。
只有六种情况需要对类进行初始化:
- 遇到new、getstatic、putstatic、或invokestatic这四条字节码指令时,如果类型没哟进行过初始化,则需要先触发其初始化阶段。能够生成四条指令的典型Java场景有:
- 使用new关键字实例化对象的时候
- 读取或设置一个类型的静态字段的时候
- 调用一个类型的静态方法的时候
- 使用java.lang.reflect包下的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发初始化
- 当初始化类的时候,如果发现父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 使用JDK7加入的动态语言支持
- 当一个接口中定义了JDK8中新加入的默认方法
4.2.2 三层类加载器
类与类加载器:
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用确远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器都有一个独立的类命名空间。比较两个类是否相等是需要两个类在同一个类加载器的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必定不同。
自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。对于这个时期的Java应用,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载。
-
启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
-
扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
-
应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

三层类加载器
4.2.3 双亲委派机制
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
为什么需要使用双亲委派机制:
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

4.2.4 双亲委派机制的破坏
- 重写loadClass方法 破坏双亲委派机制
- 使用线程上下文类加载器
- 代码热替换、模块热部署
4.2.5 如何实现一个热加载和热部署
什么是热加载?
热加载是指可以在不重启服务的情况下让更改的代码生效,热加载可以显著的提升开发以及调试的效率,它是基于 Java 的类加载器实现的,但是由于热加载的不安全性,一般不会用于正式的生产环境。
热加载与热部署的区别
首先,不管是热加载还是热部署,都可以在不重启服务的情况下编译/部署项目,都是基于 Java 的类加载器实现的。
在部署方式上:
-
热部署是在服务器运行时重新部署项目。
-
热加载是在运行时重新加载 class。
在实现原理上:
- 热部署是直接重新加载整个应用,耗时相对较高。
- 热加载是在运行时重新加载 class,后台会启动一个线程不断检测你的类是否改变。
在使用场景上:
- 热部署更多的是在生产环境使用。
- 热加载则更多的是在开发环境上使用。线上由于安全性问题不会使用,难以监控。
4.2.6 如何实现类的热加载
分析:
Java 程序在运行的时候,首先会把 class 类文件加载到 JVM 中,而类的加载过程又有五个阶段,五个阶段中只有加载阶段用户可以进行自定义处理,所以我们如果能在程序代码更改且重新编译后,让运行的进程可以实时获取到新编译后的 class 文件,然后重新进行加载的话,那么理论上就可以实现一个简单的 Java 热加载。
所以我们可以得出实现思路:
- 实现自己的类加载器。
- 从自己的类加载器中加载要热加载的类。
- 不断轮询要热加载的类 class 文件是否有更新。
- 如果有更新,重新加载。
自定义类加载器:
设计 Java 虚拟机的团队把类的加载阶段放到的 JVM 的外部实现( 通过一个类的全限定名来获取描述此类的二进制字节流 )。这样就可以让程序自己决定如果获取到类信息。而实现这个加载动作的代码模块,我们就称之为 “类加载器”。
在 Java 中,类加载器也就是 java.lang.ClassLoader. 所以如果我们想要自己实现一个类加载器,就需要继承 ClassLoader 然后重写里面 findClass的方法,同时因为类加载器是 双亲委派模型实现(也就说。除了一个最顶层的类加载器之外,每个类加载器都要有父加载器,而加载时,会先询问父加载器能否加载,如果父加载器不能加载,则会自己尝试加载)所以我们还需要指定父加载器。
定义要类型热加载的类:
假设某个接口(BaseManager.java)下的某个方法(logic)要进行热加载处理。
-
首先定义接口信息。
-
写一个这个接口的实现类。
-
后面我们要做的就是让这个类可以通过我们的 MyClassLoader 进行自定义加载。类的热加载应当只有在类的信息被更改然后重新编译之后进行重新加载。所以为了不意义的重复加载,我们需要判断 class 是否进行了更新,所以我们需要记录 class 类的修改时间,以及对应的类信息。
所以编译一个类用来记录某个类对应的某个类加载器以及上次加载的 class 的修改时间。
热加载获取类信息:
轮询检查 class 文件是不是被更新过,所以每次调用要热加载的类时,我们都要进行检查类是否被更新然后决定要不要重新加载。
4.3 垃圾回收
哪些内存需要回收?
在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理,我们平时所说的内存分配与回收也仅仅特指这一部分内存。
4.3.1 对象的存活
判断一个对象是不是垃圾,有如下两种方法:引用计数算法和可达性分析,主流的JVM用的是可达性分析
引用计数算法:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
可达性分析:
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
-
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
-
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
-
在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
-
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
-
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
安全点:GC Roots需要暂停线程的,那么有的线程正在执行的话必须强制到达安全点之后才能暂停。
安全区域:有些被sleep或blocked的线程没办法进去安全点,所以就引入了安全区域。安全区域是指确保在某一段代码片段,引用关系不会发生变化,因此在这个区域的任意地方开始垃圾回收都是安全的。
记忆集与卡表:记录从非收集区域指向收集区域的指针集合的抽象数据结构。卡表是记忆集的一种实现方式,卡表使用写屏障来维护数据。
并发的可达性分析:使用三色标记。白色没有访问过,黑色代表,访问过,无序重新扫描,灰色代表,访问过,但还有引用没有被扫描。
GC Roots不可达的对象是否一定回收?
即使在可达性分析中判定为不可达的对象,也不是必须要回收的,一个对象判断是否需要真正的回收最多经历过两次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么将会被第一次标记,随后要进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。加入对象没有finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都认为没必要执行,垃圾直接回收。
如果这个对象被判定为有必要执行finalize()方法,那么该对象将会被放置在一个队列中,并在稍后由一条虚拟机自动创建的、低调度优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,收集器会对队列中的对象进行第二次标记,如果对象在finalize()方法中拯救了自己(只要重新与引用链上的任何一个对象建立关联即可),那么第二次标记时将会被移出即将回收的集合;如果对象这时候还没有逃脱,那么就真的要被回收了。
回收方法区:
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
-
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
-
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
-
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4.3.2 垃圾回收算法
分代收集理论:
-
弱分代假说:绝大多数对象都是朝生夕灭的
-
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
-
跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
所以根据分代理论一般将内存分为新生代与老年代,新生代的对象大都朝生夕灭,老年代的对象难以消亡。
下面介绍三种垃圾回收算法:
标记-清除算法:
算法分为两个阶段:标记和清除;首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程
缺点:
- 执行效率不稳定。如果大量对象都需要回收,必须进行大量标记和清除动作
- 内存空间的碎片化。标记清除之后会产生大量不连续的内存碎片。
标记-复制算法:
“半区复制”:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过内存空间一次性清理掉。
缺点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存复制开销,内存缩小为原来的一半,空间浪费。
优点:多数对象都是可回收的情况,算法需要复制的就占少数的存活对象;分配内存时不用考虑有空间碎片的复杂情况。
标记-整理算法:
标记过程仍然与标记-清除算法相同,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
标记-复制算法在对象存活率较高时,就需要进行较多的复制操作,效率将会降低;更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代中一般不能直接选用这种算法。
移动存活对象,尤其在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有应用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序(STW
STW:Stop The World:产生情况
-
- 标记-整理算法中移动存活对象
- GC Roots根结点枚举
4.3.3 垃圾回收器

新生代:Serial、ParNew、Parallel Scavenge 都是基于标记-复制算法
老年代:CMS(标记-清除)、Serial Old(标记-整理)、Parallel Old (标记-整理)
不分代:G1
JVM中一次完整的GC流程:
- 新生代的典型垃圾回收器是 ParNew 垃圾回收器,它按照8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区,某一时刻,我们创建的对象将 Eden 区全部挤满,这个对象就是挤满新生代的最后一个对象。此时,Minor GC 就触发了。
- 在正式的Minor GC之前,JVM首先检查老年代的剩余空间是不是比新生代的对象大还是小。(为什么要判断?因为假如Minor GC后Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要检查老年代是否够用)
- 老年代剩余空间比新生代对象大,直接Minor GC结束。
- 老年代剩余空间比新生代对象小,这时候要看是否启用了老年代空间分配担保规则
- 如果开启了 老年代分配规则
- 老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 Minor GC结束;
- 老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。如果放的下那么进行Minor GC,如果放不下就抛OOM异常
- 没有开启老年代分配规则,直接Full GC,之后如果放的下那么进行Minor GC,如果放不下就抛OOM异常
- 如果开启了 老年代分配规则

Full GC会导致什么?
Full GC会“Stop The World”,即在GC期间全程暂停用户的应用程序。
对象如何晋升到老年代?
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
为什么要设置两个Survivor区域?
设置两个 Survivor 区最大的好处就是解决内存碎片化。
一个Survivor 区的缺陷:我们先假设一下,Survivor 只有一个区域会怎样。Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。
两个Survivor 区的好处:有 2 个区域,可以使用标记复制算法,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的
CMS垃圾收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:
-
初始标记(CMS initial mark);
-
并发标记(CMS concurrent mark);
-
重新标记(CMS remark);
-
并发清除(CMS concurrent sweep)。
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS缺点:
-
首先,CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
-
然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop TheWorld”的Full GC的产生。
-
还有最后一个缺点,CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
G1垃圾收集器:(Garbage First)
Garbage First(简称G1)收集器是一款主要面向服务端应用的垃圾收集器。面向堆内存任何部分来组成回收集进行回收, 不再是要么面向新生代、要么面向老年代、要么面向整个Java堆,衡量标准不再是属于哪个分代,而是哪块内存中存放的 垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1的回收步骤:
- 初始标记:仅仅标记GC Roots能直接关联到的对象。修改TAMS指针的值让下一阶段用户线程并发运行时能够正确的分配对象。需要停顿用户线程。
- 并发标记:从GC Roots开始进行可达性分析,找出需要回收的对象,可与用户线程并发。扫描完成后还需要重新处理SATB记录下在并发时有引用变动的对象。
- 最终标记:短暂的暂停用户线程,用于处理并发阶段发生结束后遗留下来的最后那少量的 SATB记录。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后决定回收的那一部分Region的存活对象复制到空的Region中,再清除整个旧的Region,这里涉及到存活对象的移动,所以需要暂停用户线程。
4.4 内存泄露和内存溢出
内存泄漏(memory leak):内存泄漏指程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏。根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象已经不再需要,但由于长生命周期对象持有它的引用而导致不能被回收。
产生情况:
- 单例造成的内存泄漏:由于单例的静态特性使得其生命周期和应用的生命周期一样长,如果一个对象已经不再需要使用了,而单例对象还持有该对象的引用,就会使得该对象不能被正常回收,从而导致了内存泄漏。
- 非静态内部类创建静态实例造成的内存泄漏
- Handler造成的内存泄漏
- 线程造成的内存泄漏
- 资源未关闭造成的内存泄漏
- 集合容器中的内存泄露
避免内存泄漏的几点建议:
-
尽早释放无用对象的引用。
-
避免在循环中创建对象。
-
使用字符串处理时避免使用String,应使用StringBuffer。
-
尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收
内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有以下几种:
-
内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
-
集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
-
代码中存在死循环或循环产生过多重复的对象实体;
-
使用的第三方软件中的BUG;
-
启动参数内存值设定的过小。
内存溢出的解决方案:
第一步,修改JVM启动参数,直接增加内存。
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
第四步,使用内存查看工具动态查看内存使用情况。
5、IO
5.1 NIO实现原理
Java的NIO主要由三个核心部分组成:Channel、Buffer、Selector。
基本上,所有的IO在NIO中都从一个Channel开始,数据可以从Channel读到Buffer中,也可以从Buffer写到Channel中。Channel有好几种类型,其中比较常用的有FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel等,这些通道涵盖了UDP和TCP网络IO以及文件IO。
Buffer本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。Java NIO里关键的Buffer实现有CharBuffer、ByteBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。这些Buffer覆盖了你能通过IO发送的基本数据类型,即byte、short、int、long、float、double、char。
Buffer对象包含三个重要的属性,分别是capacity、position、limit,其中position和limit的含义取决于Buffer处在读模式还是写模式。但不管Buffer处在什么模式,capacity的含义总是一样的。
-
capacity:作为一个内存块,Buffer有个固定的最大值,就是capacity。Buffer只能写capacity个数据,一旦Buffer满了,需要将其清空才能继续写数据往里写数据。
-
position:当写数据到Buffer中时,position表示当前的位置。初始的position值为0。当一个数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity–1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
-
limit:在写模式下,Buffer的limit表示最多能往Buffer里写多少数据,此时limit等于capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据,此时limit会被设置成写模式下的position值。
三个属性之间的关系,如下图所示:

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

5.2 序列化与反序列化
序列化机制可以将对象转换成字节序列,这些字节序列可以保存在磁盘上,也可以在网络中传输,并允许程序将这些字节序列再次恢复成原来的对象。其中,对象的序列化(Serialize),是指将一个Java对象写入IO流中,对象的反序列化(Deserialize),则是指从IO流中恢复该Java对象。
若对象要支持序列化机制,则它的类需要实现Serializable接口,该接口是一个标记接口,它没有提供任何方法,只是标明该类是可以序列化的,Java的很多类已经实现了Serializable接口,如包装类、String、Date等。
数据库
0、JDBC
0.1 JDBC编程六步(需要背会)
第一步:注册驱动(告诉java程序,即将要连接是哪个品牌的数据库)
第二步:获取连接(表示JVM的进程很数据库进程之间的通道打开了,这属于进程之间的通信,重量级的,使用完之后一定要关闭)
第三步:获取数据库操作对象(专门执行SQL语句的对象)
第四步:执行SQL语句(DQL、DML)
第五步:处理查询结果(只有当第四步执行的是select语句的时候,才有这第五步的查询结果集)
第六步:释放资源(使用完资源之后,一定要关闭资源,java和数据库属于进程之间的通信,一定要关闭)
0.2 代码实现
public static void main(String[] args) { Connection conn = null; PreparedStatement ps = null; try { Class.forName("com.mysql.cj.jdbc.Driver"); //注册驱动 String url = "jdbc:mysql://localhost:3306/cjm?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT"; String userName = "root"; String password = "960915"; conn = DriverManager.getConnection(url,userName,password); //获取连接(根据url、用户名、密码连接) String sql="insert into users(userName,password,sex,email)"+ "values(?,?,?,?)"; ps = conn.prepareStatement(sql); //获取数据库操作对象 PreparedStatement(可以预编译) Statement(不会预编译,存在SQL注入风险) ps.setString(1,"allen"); //将SQL中占位符的参数替换成值,按照顺序一个一个设置 ps.setString(2,"123"); ps.setString(3,"男"); ps.setString(4,"allen@163.com"); int result = ps.executeUpdate(); //执行SQL //处理查询结果(如果有的话) } catch (ClassNotFoundException | SQLException e) { e.printStackTrace(); }finally { if (ps != null) { try { ps.close(); //释放资源 先释放PreparedStatement } catch (SQLException throwables) { throwables.printStackTrace(); } } if (conn != null) { try { conn.close(); //释放资源 再释放Connection } catch (SQLException throwables) { throwables.printStackTrace(); } } }}1、mysql (关系型数据库)
1.1、sql语句
DQL:(数据查询语言)查询语句,凡是select语句都是DML:(数据操作语言)insert、delete、update,对表当中的数据进行增删改DDL:(数据定义语言)create、drop、alter对表进行增删改TCL:(事务控制语言)commit提交事务,rollback回滚事务DCL:(数据控制语言)grant授权,revoke撤销权限等
查询语句: select 字段1,字段2。。。 from 表名 where 条件1 group by 字段名 (分组) having 条件 (分组条件) order by 字段名 (排序)插入语句: insert into 表名(字段名1,字段名2,字段名3..... ) values(值1, 值2 ,值3....);修改语句: update 表名 set 字段名1 = 值1,字段名2 = 值2 . . .where 条件;删除语句: delete from 表名 where 条件 ;建表语句: create table 表名 ( 字段名1 数据类型 约束 , 字段名2 数据类型 约束 , 字段名3 数据类型 约束 , 字段名4 数据类型 约束 , ......... ) ;删表语句: drop table 表名;1.1.1 SQL执行顺序

一条SQL查询语句在MySQL中如何执行的?
- 先检查该语句
是否有权限,如果没有权限,直接返回错误信息,如果有权限会先查询缓存(MySQL8.0 版本以前)。 - 如果没有缓存,分析器进行
词法分析,提取 sql 语句中 select 等关键元素,然后判断 sql 语句是否有语法错误,比如关键词是否正确等等。 - 最后优化器确定执行方案进行权限校验,如果没有权限就直接返回错误信息,如果有权限就会
调用数据库引擎接口,返回执行结果。
1.1.2 MySql分页语法
在MySQL中,SELECT语句默认返回所有匹配的行,它们可能是指定表中的每个行。为了返回第一行或前几行,可使用LIMIT子句,以实现分页查询。LIMIT子句的语法如下:LIMIT 偏移量 每页大小
-- 在所有的查询结果中,返回前5行记录。SELECT prod_name FROM products LIMIT 5;-- 在所有的查询结果中,从第5行开始,返回5行记录。SELECT prod_name FROM products LIMIT 5,5;优化LIMIT分页
在偏移量非常大的时候,例如 LIMIT 10000,20 这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面的10000条记录都将被抛弃,这样的代价是非常高的。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。
优化此类分页查询的一个最简单的办法就是尽可能地使用索引覆盖扫描,而不是查询所有的列,然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样做的效率会提升非常大。考虑下面的查询:
SELECT film_id,description FROM sakila.film ORDER BY title LIMIT 50,5;如果这个表非常大,那么这个查询最好改写成下面的样子:
SELECT film.film_id,film.descriptionFROM sakila.filmINNER JOIN ( SELECT film_id FROM sakila.film ORDER BY title LIMIT 50,5) AS lim USING(film_id);//要求using()指定的列在两个表中均存在,并使用之用于join的条件这里的“延迟关联”将大大提升查询效率,它让MySQL扫描尽可能少的页面,获取需要访问的记录后再根据关联列回原表查询需要的所有列。这个技术也可以用于优化关联查询中的LIMIT子句。
有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:
SELECT film_id,description FROM skila.filmWHERE position BETWEEN 50 AND 54 ORDER BY position;LIMIT和OFFSET的问题,其实是OFFSET的问题,它会导致MySQL扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。
1.1.3 聚合函数
常用的聚合函数有COUNT()、AVG()、SUM()、MAX()、MIN(),下面以MySQL为例,说明这些函数的作用。
COUNT()函数:
COUNT()函数统计数据表中包含的记录行的总数,或者根据查询结果返回列中包含的数据行数,它有两种用法:
-
**COUNT(*)**计算表中总的行数,不管某列是否有数值或者为空值。
-
count(*)在innodb引擎和myisam引擎下有什么区别?
myisam保存表的总行数,因此count(*),很快会返回表的总行数
innodb查询count(*),mysql会对表进行全表或全索引扫描来确定行数
-
-
COUNT(字段名)计算指定列下总的行数,计算时将忽略空值的行。
COUNT()函数可以与GROUP BY一起使用来计算每个分组的总和。
AVG()函数():
AVG()函数通过计算返回的行数和每一行数据的和,求得指定列数据的平均值。
AVG()函数可以与GROUP BY一起使用,来计算每个分组的平均值。
SUM()函数:
SUM()是一个求总和的函数,返回指定列值的总和。
SUM()可以与GROUP BY一起使用,来计算每个分组的总和。
MAX()函数:
MAX()返回指定列中的最大值。
MAX()也可以和GROUP BY关键字一起使用,求每个分组中的最大值。
MAX()函数不仅适用于查找数值类型,也可应用于字符类型。
MIN()函数:
MIN()返回查询列中的最小值。
MIN()也可以和GROUP BY关键字一起使用,求出每个分组中的最小值。
MIN()函数与MAX()函数类似,不仅适用于查找数值类型,也可应用于字符类型。
where和having的区别? 1. WHERE是一个约束声明,使用WHERE约束来自数据库的数据,WHERE是在结果返回之前起作用的,WHERE中不能使用聚合函数。 2. HAVING是一个过滤声明,是在查询返回结果集以后对查询结果进行的过滤操作,在HAVING中可以使用聚合函数。另一方面,HAVING子句中不能使用除了分组字段和聚合函数之外的其他字段。
从性能的角度来说,HAVING子句中如果使用了分组字段作为过滤条件,应该替换成WHERE子句。因为WHERE可以在执行分组操作和计算聚合函数之前过滤掉不需要的数据,性能会更好。1.1.4 表连接
表与表之间常用的关联方式有两种:内连接、外连接,下面以MySQL为例来说明这两种连接方式。
内连接:
内连接通过INNER JOIN来实现,它将返回两张表中满足连接条件的数据,不满足条件的数据不会查询出来。
外连接:
外连接通过OUTER JOIN来实现,它会返回两张表中满足连接条件的数据,同时返回不满足连接条件的数据。外连接有两种形式:左外连接(LEFT OUTER JOIN)、右外连接(RIGHT OUTER JOIN)。
-
**左外连接:**可以简称为左连接(LEFT JOIN),它会返回左表中的所有记录和右表中满足连接条件的记录。
-
**右外连接:**可以简称为右连接(RIGHT JOIN),它会返回右表中的所有记录和左表中满足连接条件的记录。
除此之外,还有一种常见的连接方式:等值连接。这种连接是通过WHERE子句中的条件,将两张表连接在一起,它的实际效果等同于内连接。出于语义清晰的考虑,一般更建议使用内连接,而不是等值连接。
实际上,外连接还有一种形式:完全外连接(FULL OUTER JOIN),但MySQL不支持这种形式
以上是从语法上来说明表与表之间关联的实现方式,而从表的关系上来说,比较常见的关联关系有:一对多关联、多对多关联、自关联。
-
一对多关联:这种关联形式最为常见,一般是两张表具有主从关系,并且以主表的主键关联从表的外键来实现这种关联关系。另外,以从表的角度来看,它们是具有多对一关系的,所以不再赘述多对一关联了。
-
多对多关联:这种关联关系比较复杂,如果两张表具有多对多的关系,那么它们之间需要有一张中间表来作为衔接,以实现这种关联关系。这个中间表要设计两列,分别存储那两张表的主键。因此,这两张表中的任何一方,都与中间表形成了一对多关系,从而在这个中间表上建立起了多对多关系。
-
自关联:自关联就是一张表自己与自己相关联,为了避免表名的冲突,需要在关联时通过别名将它们当做两张表来看待。一般在表中数据具有层级(树状)时,可以采用自关联一次性查询出多层级的数据。
如何将一张表的部分数据更新到另一张表上?
可以采用关联更新的方式,将一张表的部分数据,更新到另一张表内。参考如下代码:
update b set b.col=a.col from a,b where a.id=b.id; #等值连接update b set col=a.col from b inner join a on a.id=b.id; #内连接update b set b.col=a.col from b left Join a on b.id = a.id;#外连接1.1.5 谈谈对SQL注入的理解
SQL注入的原理是将SQL代码伪装到输入参数中,传递到服务器解析并执行的一种攻击手法。也就是说,在一些对SERVER端发起的请求参数中植入一些SQL代码,SERVER端在执行SQL操作时,会拼接对应参数,同时也将一些SQL注入攻击的“SQL”拼接起来,导致会执行一些预期之外的操作。
举个例子:
比如我们的登录功能,其登录界面包括用户名和密码输入框以及提交按钮,登录时需要输入用户名和密码,然后提交。此时调用接口/user/login/ 加上参数username、password,首先连接数据库,然后后台对请求参数中携带的用户名、密码进行参数校验,即SQL的查询过程。假设正确的用户名和密码为ls和123456,输入正确的用户名和密码、提交,相当于调用了以下的SQL语句。
SELECT * FROM user WHERE username = 'ls' AND password = '123456' SQL中会将#及—以后的字符串当做注释处理,如果我们使用 ’ or 1=1 # 作为用户名参数,那么服务端构建的SQL语句就如下:
select * from user where username='' or 1=1 #' and password='123456' 而#会忽略后面的语句,而1=1属于常等型条件,因此这个SQL将查询出所有的登录用户。其实上面的SQL注入只是在参数层面做了些手脚,如果是引入了一些功能性的SQL那就更危险了,比如上面的登录功能,如果用户名使用这个 ’ or 1=1;delete * from users; # ,那么在”;“之后相当于是另外一条新的SQL,这个SQL是删除全表,是非常危险的操作,因此SQL注入这种还是需要特别注意的。
如何解决SQL注入:
-
严格的参数校验:参数校验就没得说了,在一些不该有特殊字符的参数中提前进行特殊字符校验即可。
-
SQL预编译:在知道了SQL注入的原理之后,我们同样也了解到MySQL有预编译的功能,指的是在服务器启动时,MySQL Client把SQL语句的模板(变量采用占位符进行占位)发送给MySQL服务器,MySQL服务器对SQL语句的模板进行编译,编译之后根据语句的优化分析对相应的索引进行优化,在最终绑定参数时把相应的参数传送给MySQL服务器,直接进行执行,节省了SQL查询时间,以及MySQL服务器的资源,达到一次编译、多次执行的目的,除此之外,还可以防止SQL注入。具体是怎样防止SQL注入的呢?实际上当将绑定的参数传到MySQL服务器,MySQL服务器对参数进行编译,即填充到相应的占位符的过程中,做了转义操作。我们常用的JDBC就有预编译功能,不仅提升性能,而且防止SQL注入。
1.2、索引
1.2.1 说一说对MySql索引的理解
什么是索引?
索引是帮助MySQL高效获取数据的数据结构。
索引能干什么?
索引非常关键,尤其是当表中的数据量越来越大时,索引对于性能的影响愈发重要。索引能够轻易将查询性能提高好几个数量级,总的来说就是可以明显的提高查询效率。
索引的分类?
-
从存储结构上来划分:B+Tree索引,Hash索引,full-index全文索引。
-
从应用层次来分:普通索引,唯一索引,组合索引
-
根据数据的物理顺序与键值的逻辑(索引)顺序关系:聚集索引,非聚集索引。
全文索引:基于相似度的查询,在大数据量的情况下比like快很多。
普通索引:即一个索引只包含单个列,一个表可以有多个单列索引
唯一索引:索引列的值必须唯一,但允许有空值
组合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并,最左前缀原则,最左匹配?走索引:不走索引
最左前缀原则,就是最左优先,在创建多列索引时,要根据业务需求,where 子句中使用最频繁的一列放在最左边。当我们创建一个组合索引的时候,如 (a1,a2,a3),相当于创建了(a1)、(a1,a2)和(a1,a2,a3)三个索引,这就是最左匹配原则。聚簇索引(聚集索引):并不是一种单独的索引类型,而是一种数据存储方式。具体细节取决于不同的实现,InnoDB的聚簇索引其实就是在同一个结构中保存了B+Tree索引和数据行。
非聚簇索引:不是聚簇索引,就是非聚簇索引
聚簇索引是根据主键创建的一棵B+树,聚簇索引的叶子节点存放了表中的所有记录。辅助索引是根据索引键创建的一棵B+树,与聚簇索引不同的是,其叶子节点仅存放索引键值,以及该索引键值指向的主键。也就是说,如果通过辅助索引来查找数据,那么当找到辅助索引的叶子节点后,很有可能还需要根据主键值查找聚簇索引来得到数据,这种查找方式又被称为书签查找。因为辅助索引不包含行记录的所有数据,这就意味着每页可以存放更多的键值,因此其高度一般都要小于聚簇索引。
索引的底层实现?
mysql默认存储引擎InnoDB只显式支持B+Tree索引,对于频繁访问的表,innodb会透明建立自适应hash索引,即在B+树索引基础上建立hash索引,可以显著提高查找效率,对于客户端是透明的,不可控制的,隐式的。
哈希索引:
基于哈希表实现,只有精确匹配索引所有列的查询才有效,对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),并且Hash索引将所有的哈希码存储在索引中,同时在索引表中保存指向每个数据行的指针。
为什么官方建议使用自增长主键作为索引?
结合B+Tree的特点,自增主键是连续的,在插入过程中尽量减少页分裂,即使要进行页分裂,也只会分裂很少一部分。并且能减少数据的移动,每次插入都是插入到最后。总之就是减少分裂和移动的频率。
1.2.2 B-树、B+树索引
介绍:
我们在MySQL中的数据一般是放在磁盘中的,读取数据的时候肯定会有访问磁盘的操作,磁盘中有两个机械运动的部分,分别是盘片旋转和磁臂移动。盘片旋转就是我们市面上所提到的多少转每分钟,而磁盘移动则是在盘片旋转到指定位置以后,移动磁臂后开始进行数据的读写。那么这就存在一个定位到磁盘中的块的过程,而定位是磁盘的存取中花费时间比较大的一块,毕竟机械运动花费的时候要远远大于电子运动的时间。当大规模数据存储到磁盘中的时候,显然定位是一个非常花费时间的过程,但是我们可以通过B树进行优化,提高磁盘读取时定位的效率。
为什么B类树可以进行优化呢?我们可以根据B类树的特点,构造一个多阶的B类树,然后在尽量多的在结点上存储相关的信息,保证层数尽量的少,以便后面我们可以更快的找到信息,磁盘的I/O操作也少一些,而且B类树是平衡树,每个结点到叶子结点的高度都是相同,这也保证了每个查询是稳定的。
总的来说,B/B+树是为了磁盘或其它存储设备而设计的一种平衡多路查找树(相对于二叉,B树每个内节点有多个分支),与红黑树相比,在相同的的节点的情况下,一颗B/B+树的高度远远小于红黑树的高度(在下面B/B+树的性能分析中会提到)。B/B+树上操作的时间通常由存取磁盘的时间和CPU计算时间这两部分构成,而CPU的速度非常快,所以B树的操作效率取决于访问磁盘的次数,关键字总数相同的情况下B树的高度越小,磁盘I/O所花的时间越少。
注意B-树就是B树,-只是一个符号。
**B-树索引:是一种多路平衡查找树,他的每一个节点最多包含M个孩子,M就是B树的阶。**M的大小取决于磁盘页的大小。
m阶B树:
-
树中每个结点至多有m个子结点(即M阶);
-
若根结点不是叶子结点,则至少有2个子结点;
-
除根结点和叶子结点外,其它每个结点至少有ceil(m/2)个子结点;
注:[ceil(m / 2)]个子结点(其中ceil(x)是一个取上限的函数);
即中间节点最少有ceil(m/2)个子结点。
-
所有叶子结点都出现在同一层,叶子结点不包含任何关键字信息;
-
有k个子结点的非终端结点恰好包含有k-1个关键字(单节点里元素).
每个节点中元素个数n必须满足: [ceil(m / 2)-1]<= n <= m-1。(即M阶树单节点最多有M-1个元素)
**每个结点中关键字从小到大排列,**并且当该结点的孩子是非叶子结点时,该k-1个关键字正好是k个孩子包含的关键字的值域的分划.
B树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能变低;
B+Tree索引:(MySql的InnoDB引擎默认的索引)
是B-Tree的改进版本,同时也是数据库索引索引所采用的存储结构。数据都在叶子节点上,并且增加了顺序访问指针,每个叶子节点都指向相邻的叶子节点的地址。相比B-Tree来说,进行范围查找时只需要查找两个节点,进行遍历即可。而B-Tree需要获取所有节点,相比之下B+Tree效率更高。
为什么说B+树比B树更适合数据库索引?
1、 B+树的磁盘读写代价更低:B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B树更小,如果把所有同一内部节点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多,一次性读入内存的需要查找的关键字也就越多,相对IO读写次数就降低了。
2、B+树的查询效率更加稳定:由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
3、由于B+树的数据都存储在叶子结点中,分支结点均为索引,方便扫库,只需要扫一遍叶子结点即可,但是B树因为其分支结点同样存储着数据,我们要找到具体的数据,需要进行一次中序遍历按序来扫,所以B+树更加适合在区间查询的情况,所以通常B+树用于数据库索引。
B树在提高了IO性能的同时并没有解决元素遍历的效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低。InnoDB索引和MyISAM索引的区别:(Mysql的不同引擎)
MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。
第一个重大区别是InnoDB的数据文件本身就是索引文件。从上文知道,MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。
第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。
为什么索引结构默认使用B+Tree,而不是Hash,二叉树,红黑树?
B-tree:B+ 树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快。
Hash:虽然可以快速定位,但是没有顺序,IO复杂度高。
二叉树:如果二叉树特殊化为一个链表,相当于全表扫描。平衡二叉树相比于二叉查找树来说,查找效率更稳定,总体的查找速度也更快。
平衡二叉树:我们知道,在内存比在磁盘的数据,查询效率快得多。如果树这种数据结构作为索引,那我们每查找一次数据就需要从磁盘中读取一个节点,也就是我们说的一个磁盘块,但是平衡二叉树可是每个节点只存储一个键值和数据的,如果是B树,可以存储更多的节点数据,树的高度也会降低,因此读取磁盘的次数就降下来啦,查询效率就快啦。
红黑树:树的高度随着数据量增加而增加,IO代价高
1.2.3 哈希索引
基于哈希表实现,只有匹配所有列的查询才有效。对于每一行数据,存储引擎都会对所有索引列计算一个哈希码,哈希码是一个较小的值,不同键值的行计算出的哈希码也不一样。哈希索引将所有的哈希码存储在索引中,同时保存指向每个数据行的指针。
Hash索引和B树索引有什么区别?1. B+ 树可以进行范围查询,Hash 索引不能。2. B+ 树支持联合索引的最左侧原则,Hash 索引不支持。3. B+ 树支持 order by 排序,Hash 索引不支持。4. Hash 索引在等值查询上比 B+ 树效率更高。5. hash索引虽然在等值查询上较快,但是不稳定,性能不可预测,当某个键值存在大量重复的时候,发生hash碰撞,此时效率可能极差。而B+树的查询效率比较稳定,对于所有的查询都是从根节点到叶子节点,且树的高度较低。6. B+ 树使用 like 进行模糊查询的时候,like 后面(比如%开头)的话可以起到优化的作用,Hash 索引根本无法进行模糊查询1.2.4 全文索引
通过数值比较、范围过滤等就可以完成绝大多数我们需要的查询,但是,如果希望通过关键字的匹配来进行查询过滤,那么就需要基于相似度的查询,而不是原来的精确数值比较。全文索引就是为这种场景设计的。
like + % 在文本比较少时是合适的,但是对于大量的文本数据检索,是不可想象的。全文索引在大量的数据面前,能比 like + % 快 N 倍,速度不是一个数量级,但是全文索引可能存在精度问题。
开始之前,先说一下全文索引的版本、存储引擎、数据类型的支持情况
- MySQL 5.6 以前的版本,只有 MyISAM 存储引擎支持全文索引;- MySQL 5.6 及以后的版本,MyISAM 和 InnoDB 存储引擎均支持全文索引;
- 只有字段的数据类型为 char、varchar、text 及其系列才可以建全文索引。
1.2.5 MySQL怎么判断要不要加索引?
-
当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度。
-
在频繁进行排序或分组(即进行group by或order by操作)的列上建立索引,如果待排序的列有多个,可以在这些列上建立组合索引。
1.2.6 只要创建了索引,就一定会走索引吗?
不一定。
比如,在使用组合索引的时候,如果没有遵从“最左前缀”的原则进行搜索,则索引是不起作用的。举例,假设在id、name、age字段上已经成功建立了一个名为MultiIdx的组合索引。索引行中按id、name、age的顺序存放,索引可以搜索id、(id,name)、(id, name, age)字段组合。如果列不构成索引最左面的前缀,那么MySQL不能使用局部索引,如(age)或者(name,age)组合则不能使用该索引查询。
1.2.7 所有的字段都适合创建索引吗?
不是。
下列几种情况,是不适合创建索引的:
- 频繁更新的字段不适合建立索引;
- where条件中用不到的字段不适合建立索引;
- 数据比较少的表不需要建索引;
- 数据重复且分布比较均匀的的字段不适合建索引,例如性别、真假值;
- 参与列计算的列不适合建索引。
1.2.8 索引失效
索引失效情况:
-
使用组合索引时,没有遵循“最左前缀”原则;
-
在索引列上做操作,例如计算、函数、类型转换,会导致索引失效而转向全表扫描;
-
使用 select * 覆盖索引;
-
MySQL在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描;
-
LIKE以通配符开头(%abc)MySQL索引会失效变成全表扫描的操作;
-
字符串不加单引号会导致索引失效(可能发生了索引列的隐式转换);
-
用or,用它来连接时会索引失效。
可以采用以下几种方式,来避免索引失效:
-
使用组合索引时,需要遵循“最左前缀”原则;
-
不在索引列上做任何操作,例如计算、函数、类型转换,会导致索引失效而转向全表扫描;
-
尽量使用覆盖索引(之访问索引列的查询),减少 select * 覆盖索引能减少回表次数;
-
MySQL在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描;
-
LIKE以通配符开头(%abc)MySQL索引会失效变成全表扫描的操作;
-
字符串不加单引号会导致索引失效(可能发生了索引列的隐式转换);
-
少用or,用它来连接时会索引失效。
1.3、事务
1.3.1 说一说你对数据库事务的了解
事务可由一条非常简单的SQL语句组成,也可以由一组复杂的SQL语句组成。在事务中的操作,要么都执行修改,要么都不执行,这就是事务的目的,也是事务模型区别于文件系统的重要特征之一。
事务需遵循ACID四个特性:
-
A(atomicity),原子性。原子性指整个数据库事务是不可分割的工作单位。只有使事务中所有的数据库操作都执行成功,整个事务的执行才算成功。事务中任何一个SQL语句执行失败,那么已没事经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。
-
C(consistency),一致性。一致性指事务将数据库从一种状态转变为另一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
-
I(isolation),隔离性。事务的隔离性要求每个读写事务的对象与其他事务的操作对象能相互分离,即该事务提交前对其他事务都不可见,这通常使用锁来实现。
-
D(durability) ,持久性。事务一旦提交,其结果就是永久性的,即使发生宕机等故障,数据库也能将数据恢复。持久性保证的是事务系统的高可靠性,而不是高可用性。
1.3.2 事务有哪几种类型,它们之间有什么区别?
事务可以分为以下几种类型:
-
扁平事务:是事务类型中最简单的一种,而在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORK或ROLLBACK WORK结束。处于之间的操作是原子的,要么都执行,要么都回滚。
-
带有保存点的扁平事务:除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态,这是因为可能某些事务在执行过程中出现的错误并不会对所有的操作都无效,放弃整个事务不合乎要求,开销也太大。保存点(savepoint)用来通知系统应该记住事务当前的状态,以便以后发生错误时,事务能回到该状态。
-
链事务:可视为保存点模式的一个变种。链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的。
-
嵌套事务:是一个层次结构框架。有一个顶层事务(top-level transaction)控制着各个层次的事务。顶层事务之下嵌套的事务被称为子事务(subtransaction),其控制每一个局部的变换。
-
分布式事务:通常是一个在分布式环境下运行的扁平事务,因此需要根据数据所在位置访问网络中的不同节点。对于分布式事务,同样需要满足ACID特性,要么都发生,要么都失效。
对于MySQL的InnoDB存储引擎来说,它支持扁平事务、带有保存点的扁平事务、链事务、分布式事务。对于嵌套事务,MySQL数据库并不是原生的,因此对于有并行事务需求的用户来说MySQL就无能为力了,但是用户可以通过带有保存点的事务来模拟串行的嵌套事务。
1.3.3 MySQL的ACID特性分别是怎么实现的?
原子性实现原理:
实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。InnoDB实现回滚靠的是undo log,当事务对数据库进行修改时,InnoDB会生成对应的undo log。如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。
undo log属于逻辑日志,它记录的是sql执行相关的信息。当发生回滚时,InnoDB会根据undo log的内容做与之前相反的工作。对于insert,回滚时会执行delete。对于delete,回滚时会执行insert。对于update,回滚时则会执行相反的update,把数据改回去。
**持久性实现原理:**数据写入磁盘
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中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:
-
刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作,属于顺序IO。
-
刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入。而redo log中只包含真正需要写入的部分,无效IO大大减少。
隔离性实现原理:
隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作(加锁读等特殊读操作会特殊说明),那么隔离性的探讨,主要可以分为两个方面。
第一方面,(一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性。
隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁。获得锁之后,事务便可以修改数据。该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。
按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差。行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源,因此在锁定数据较多情况下使用表锁可以节省大量资源。MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。
第二方面,(一个事务)写操作对(另一个事务)读操作的影响:MVVC保证隔离性。
InnoDB默认的隔离级别是RR(REPEATABLE READ),RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:
-
隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
-
基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
-
ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。
一致性实现原理:
可以说,一致性是事务追求的最终目标。前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性的措施包括:
-
保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证。
-
数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等。
-
应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致。
1.3.4 谈谈MySQL的事务隔离级别
并发情况下,读操作可能存在的三类问题:
-
脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。
-
不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。
-
幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。
SQL 标准定义了四种隔离级别,这四种隔离级别分别是:
-
读未提交(READ UNCOMMITTED):它是性能最好、也最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。
-
读提交 (READ COMMITTED):
-
可重复读 (REPEATABLE READ):为了解决不可重复读,MySQL 采用了 MVVC (多版本并发控制) 的方式。
-
串行化 (SERIALIZABLE):读的时候加共享锁,其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读。

1.3.5 如何实现可重复读
为了实现可重复读,MySQL 采用了 MVVC (多版本并发控制) 的方式。
我们在数据库表中看到的一行记录可能实际上有多个版本,每个版本的记录除了有数据本身外,还要有一个表示版本的字段,记为 row trx_id,而这个字段就是使其产生的事务的 id,事务 ID 记为transaction id,它在事务开始的时候向事务系统申请,按时间先后顺序递增。
可重复读是在事务开始的时候生成一个当前事务全局性的快照。对于一个快照来说,它能够读到哪些版本数据,要遵循以下规则:
-
当前事务内的更新,可以读到;
-
版本未提交,不能读到;
-
版本已提交,但是却在快照创建后提交的,不能读到;
-
版本已提交,且是在快照创建前提交的,可以读到。
1.3.6 如何解决幻读问题
MySQL 已经在可重复读隔离级别下解决了幻读的问题,用的是间隙锁。MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key锁。
1.4、锁
1.4.1 数据库的锁
锁是数据库系统区别于文件系统的一个关键特性,锁机制用于管理对共享资源的并发访问。下面我们以MySQL数据库的InnoDB引擎为例,来说明锁的一些特点。
锁的类型:
InnoDB存储引擎实现了如下两种标准的行级锁:
共享锁(S Lock),允许事务读一行数据。
排他锁(X Lock),允许事务删除或更新一行数据。
锁的粒度:(锁的级别)
InnoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称之为意向锁。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。
InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:
意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁。
意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁。

锁的算法:
InnoDB存储引擎有3种行锁的算法,其分别是:
Record Lock:单个行记录上的锁。给索引上的索引项加锁来实现的。
Gap Lock:间隙锁,锁定一个范围,但不包含记录本身。是为了阻止多个事务将记录插入到同一范围内,而这会导致幻读问题的产
生。
Next-Key Lock∶Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身。
关于死锁:
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。
解决死锁问题最简单的一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。
除了超时机制,当前数据库还都普遍采用**wait-for graph(等待图)**的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。wait-for graph要求数据库保存以下两种信息:
锁的信息链表;
事务等待链表;
通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,因此资源间相互发生等待。这是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。
锁的升级:
锁升级(Lock Escalation)是指将当前锁的粒度降低。举例来说,数据库可以把一个表的1000个行锁升级为一个页锁,或者将页锁升级为表锁。
InnoDB存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,相反,其根据每个事务访问的每个页对锁进行管理的,采用的是位图的方式。因此不管一个事务锁住页中一个记录还是多个记录,其开销通常都是一致的。
1.4.2 InnoDB中行级锁是怎么实现的?
InnoDB行级锁是通过给索引上的索引项加锁来实现的。只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。
当表中锁定其中的某几行时,不同的事务可以使用不同的索引锁定不同的行。另外,不论使用主键索引、唯一索引还是普通索引,InnoDB都会使用行锁来对数据加锁。
1.4.3 mysql各引擎支持的锁?
myisam:表锁(MyISAM中是不会产生死锁的,因为MyISAM总是一次性获得所需的全部锁,要么全部满足,要么全部等待。)
innodb:行锁或表锁(在InnoDB中,锁是逐步获得的,就造成了死锁的可能。 )(行级锁并不是直接锁记录,而是锁索引。)
-
如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;
-
如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。
1.4.4 mysql如何排查死锁?
- 查看死锁日志
- 分析死锁日志
- 死锁日志分事务1,事务2拆分
- 找出发生死锁的SQL
- 找出事务持有什么锁,都在等待什么锁
- SQL加锁分析
1.5、优化
1.5.1 说一说你对数据库优化的理解
MySQL数据库优化是多方面的,原则是减少系统的瓶颈,减少资源的占用,增加系统的反应速度。例如,通过优化文件系统,提高磁盘I\O的读写速度;通过优化操作系统调度策略,提高MySQL在高负荷情况下的负载能力;优化表结构、索引、查询语句等使查询响应更快。
-
针对查询,我们可以通过使用索引、使用连接代替子查询的方式来提高查询速度。
-
针对慢查询,我们可以通过分析慢查询日志,来发现引起慢查询的原因,从而有针对性的进行优化。
-
针对插入,我们可以通过禁用索引、禁用检查等方式来提高插入速度,在插入之后再启用索引和检查。
-
针对数据库结构,我们可以通过将字段很多的表拆分成多张表、增加中间表、增加冗余字段等方式进行优化
1.5.2 该如何优化MySQL的查询?
-
使用索引:如果查询时没有使用索引,查询语句将扫描表中的所有记录。在数据量大的情况下,这样查询的速度会很慢。如果使用索引进行查询,查询语句可以根据索引快速定位到待查询记录,从而减少查询的记录数,达到提高查询速度的目的。
有几种特殊情况,在这些情况下有可能使用带有索引的字段查询时索引并没有起作用:1. 使用LIKE关键字的查询语句在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。2. 使用多列索引的查询语句MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中使用了这些字段中的第1个字段时索引才会被使用。3. 使用OR关键字的查询语句查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索引。否则,查询将不使用索引。 -
优化子查询:使用子查询可以进行SELECT语句的嵌套查询,即一个SELECT查询的结果作为另一个SELECT语句的条件。子查询可以一次性完成很多逻辑上需要多个步骤才能完成的SQL操作。在MySQL中,可以使用连接(JOIN)查询来替代子查询。连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引,性能会更好。
1.5.3 怎样插入数据才能更高效?
影响插入速度的主要是索引、唯一性校验、一次插入记录条数等。针对这些情况,可以分别进行优化。
对于InnoDB引擎的表,常见的优化方法如下:
-
禁用唯一性检查:插入数据之前执行 set unique_checks=0 来禁止对唯一索引的检查,数据导入完成之后再运行set unique_checks=1 。这个和MyISAM引擎的使用方法一样。
-
禁用外键检查:插入数据之前执行禁止对外键的检查,数据插入完成之后再恢复对外键的检查。
-
禁用自动提交:插入数据之前禁止事务的自动提交,数据导入完成之后,执行恢复自动提交操作。
1.5.4 表中包含几千万条数据该怎么办?**************
-
优化SQL和索引;
-
增加缓存,如memcached、redis;
-
读写分离,可以采用主从复制,也可以采用主主复制;
-
使用MySQL自带的分区表,这对应用是透明的,无需改代码,但SQL语句是要针对分区表做优化的;
-
做垂直拆分,即根据模块的耦合度,将一个大的系统分为多个小的系统;
-
做水平拆分,要选择一个合理的sharding key,为了有好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key,将数据定位到限定的表上去查,而不是扫描全部的表。
1.5.5 MySQL的慢查询
**慢查询核心字段?**怎么看执行计划(explain),如何理解其中各个字段的含义?
在 select 语句之前增加 explain 关键字,会返回执行计划的信息。

(1)id 列:是 select 语句的序号,MySQL将 select 查询分为简单查询和复杂查询。
(2)select_type列:表示对应行是是简单还是复杂的查询。
(3)table 列:表示 explain 的一行正在访问哪个表。
(4)type 列:最重要的列之一。表示关联类型或访问类型,即 MySQL 决定如何查找表中的行。从最优到最差分别为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
(5)possible_keys 列:显示查询可能使用哪些索引来查找。
(6)key 列:这一列显示 mysql 实际采用哪个索引来优化对该表的访问。
(7)key_len 列:显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。
(8)ref 列:这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),func,NULL,字段名。
(9)rows 列:这一列是 mysql 估计要读取并检测的行数,注意这个不是结果集里的行数。
(10)Extra 列:显示额外信息。比如有 Using index、Using where、Using temporary等。
慢查询优化
-
开启慢查询日志MySQL中慢查询日志默认是关闭的,可以通过配置文件my.ini中的添加slow_query_log = ON,也可以在MySQL服务启动的时候使用 —log-slow-queries[=file_name] 启动慢查询日志。启动慢查询日志时,需要在my.ini文件中配置long_query_time=1选项指定记录阈值,如果某条查询语句的查询时间超过了这个值,这个查询过程将被记录到慢查询日志文件中。
-
分析慢查询日志:直接分析mysql慢查询日志,利用explain关键字可以模拟优化器执行SQL查询语句,来分析sql慢查询语句
常见慢查询优化:
- 索引没起作用的情况
- 优化数据库结构
- 分解关联查询
- 优化LIMIT分页
1.5.6 日常工作中你是怎么优化SQL的?
- 优化表结构:
- 尽量使用数字型字段(字符型会降低查询和连接性能,增加存储开销)
- 尽可能使用varchar代替char(边长的存储空间小,节省存储空间)
- 当索引列有大量重复元素时,可以删除索引(如性别列 只有男、女、未知)
- 优化查询:
- 应尽量避免在 where 子句中使用!=或<>操作符
- 应尽量避免在 where 子句中使用 or 来连接条件
- 任何查询也不要出现select *
- 多取了不需要的列
- 杜绝了覆盖索引的可能:使用select * 就表示肯定用不到覆盖索引(索引是高效找到行的一个方法,当能通过检索索引就可以读取想要的数据,那就不需要再到数据表中读取行了。如果一个索引包含了(或覆盖了)满足查询语句中字段与条件的数据就叫 做覆盖索引。)
- 增加传输时间和网络开销
- 增加了IO次数:表中存在字段是大字段例如text、varchar等,当字段超过728字节
- 避免在 where 子句中对字段进行 null 值判断
- 索引优化:
- 对作为查询条件和 order by的字段建立索引
- 避免建立过多的索引,多使用组合索引
1.5.6 mysql分库分表
分库分表方案:
- 水平分库:以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。
- 水平分表:以字段为依据,按照一定策略(hash、range等),将一个表中的数据拆分到多个表中。
- 垂直分库:以表为依据,按照业务归属不同,将不同的表拆分到不同的库中。
- 垂直分表:以字段为依据,按照字段的活跃性,将表中字段拆到不同的表(主表和扩展表)中。
常用的分库分表中间件:
- sharding-jdbc
- Mycat
分库分表可能遇到的问题
- 事务问题:需要用分布式事务啦
- 跨节点Join的问题:解决这一问题可以分两次查询实现
- 跨节点的count,order by,group by以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并。
- 数据迁移,容量规划,扩容等问题
- ID问题:数据库被切分后,不能再依赖数据库自身的主键生成机制啦,最简单可以考虑UUID
- 跨分片的排序分页问题
1.6、其他
1.6.1 介绍一下数据库设计的三大范式
- 第一范式(1NF):是指在关系模型中,对于添加的一个规范要求,所有的域都应该是原子性的,即数据库表的每一列都是不可分割的原子数据项,而不能是集合,数组,记录等非原子数据项。
- 第二范式(2NF):在1NF的基础上,非码属性必须完全依赖于候选码(在1NF基础上消除非主属性对主码的部分函数依赖)。
- 第三范式(3NF):在2NF基础上,任何非主属性不依赖于其它非主属性(在2NF基础上消除传递依赖)。
1.6.2 说一说你对MySQL引擎的了解
MySQL提供了多个不同的存储引擎,包括处理事务安全表的引擎和处理非事务安全表的引擎。在MySQL中,不需要在整个服务器中使用同一种存储引擎,针对具体的要求,可以对每一个表使用不同的存储引擎。MySQL 8.0支持的存储引擎有InnoDB、MyISAM、Memory、Merge、Archive、Federated、CSV、BLACKHOLE等。其中,最常用的引擎是InnoDB和MyISAM。
InnoDB存储引擎:
InnoDB是事务型数据库的首选引擎,支持事务安全表(ACID),支持行锁定和外键。MySQL 5.5.5之后,InnoDB作为默认存储引擎,主要特性如下:
-
InnoDB给MySQL提供了具有提交、回滚和崩溃恢复能力的事务安全(ACID兼容)存储引擎。InnoDB锁定在行级并且也在SELECT语句中提供一个类似Oracle的非锁定读。这些功能增加了多用户部署和性能。在SQL查询中,可以自由地将InnoDB类型的表与其他MySQL表的类型混合起来,甚至在同一个查询中也可以混合。
-
InnoDB是为处理巨大数据量的最大性能设计。它的CPU效率可能是任何其他基于磁盘的关系数据库引擎所不能匹敌的。
-
InnoDB存储引擎完全与MySQL服务器整合,为在主内存中缓存数据和索引而维持它自己的缓冲池。InnoDB将它的表和索引存在一个逻辑表空间中,表空间可以包含数个文件(或原始磁盘分区)。这与MyISAM表不同,比如在MyISAM表中每个表被存在分离的文件中。InnoDB表可以是任何尺寸,即使在文件尺寸被限制为2GB的操作系统上。
-
InnoDB支持外键完整性约束(FOREIGN KEY)。存储表中的数据时,每张表的存储都按主键顺序存放,如果没有显示在表定义时指定主键,InnoDB会为每一行生成一个6B的ROWID,并以此作为主键。
-
InnoDB被用在众多需要高性能的大型数据库站点上。InnoDB不创建目录,使用InnoDB时,MySQL将在数据目录下创建一个名为ibdata1的10MB大小的自动扩展数据文件,以及两个名为ib_logfile0和ib_logfile1的5MB大小的日志文件。
MyISAM存储引擎:
MyISAM基于ISAM存储引擎,并对其进行扩展。它是在Web、数据仓储和其他应用环境下最常使用的存储引擎之一。MyISAM拥有较高的插入、查询速度,但不支持事务。MyISAM的主要特性如下:
-
在支持大文件(达63位文件长度)的文件系统和操作系统上被支持。
-
当把删除和更新及插入操作混合使用的时候,动态尺寸的行产生更少碎片。这要通过合并相邻被删除的块以及若下一个块被删除则扩展到下一块来自动完成。
-
每个MyISAM表最大的索引数是64,这可以通过重新编译来改变。每个索引最大的列数是16个。
-
最大的键长度是1000B,这也可以通过编译来改变。对于键长度超过250B的情况,一个超过1024B的键将被用上。
-
BLOB和TEXT列可以被索引。
-
NULL值被允许在索引的列中,这个值占每个键的0~1个字节。
-
所有数字键值以高字节优先被存储,以允许一个更高的索引压缩。
-
每个表一个AUTO_INCREMENT列的内部处理。MyISAM为INSERT和UPDATE操作自动更新这一列,这使得AUTO_INCREMENT列更快(至少10%)。在序列顶的值被删除之后就不能再利用。
-
可以把数据文件和索引文件放在不同目录。
-
每个字符列可以有不同的字符集。
-
有VARCHAR的表可以固定或动态记录长度。
-
VARCHAR和CHAR列可以多达64KB。
1.6.3 说一说你对redo log、undo log、binlog的了解
binlog(Binary Log):
二进制日志文件就是常说的binlog。二进制日志记录了MySQL所有修改数据库的操作,然后以二进制的形式记录在日志文件中,其中还包括每条语句所执行的时间和所消耗的资源,以及相关的事务信息。默认情况下,二进制日志功能是开启的,启动时可以重新配置 —log-bin[=file_name] 选项,修改二进制日志存放的目录和文件名称。
redo log:
重做日志用来实现事务的持久性,即事务ACID中的D。它由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),它是持久的。
InnoDB是事务的存储引擎,它通过Force Log at Commit机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的COMMIT操作完成才算完成。这里的日志是指重做日志,在InnoDB存储引擎中,由两部分组成,即redo log和undo log。
redo log用来保证事务的持久性,undo log用来帮助事务回滚及MVCC的功能。redo log基本上都是顺序写的,在数据库运行时不需要对redo log的文件进行读取操作。而undo log是需要进行随机读写的。
undo log:
重做日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。
redo存放在重做日志文件中,与redo不同,undo存放在数据库内部的一个特殊段(segment)中,这个段称为undo段(undo segment),undo段位于共享表空间内。
1.6.4 MySQL主从同步是如何实现的?
复制(replication)是MySQL数据库提供的一种高可用高性能的解决方案,一般用来建立大型的应用。
总体来说,replication的工作原理分为以下3个步骤:
-
主服务器(master)把数据更改记录到二进制日志(binlog)中。
-
从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(relay log)中。
-
从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。
复制的工作原理并不复杂,其实就是一个完全备份加上二进制日志备份的还原。不同的是这个二进制日志的还原操作基本上实时在进行中。这里特别需要注意的是,复制不是完全实时地进行同步,而是异步实时。这中间存在主从服务器之间的执行延时,如果主服务器的压力很大,则可能导致主从服务器延时较大。复制的工作原理如下图所示,其中从服务器有2个线程,一个是I/O线程,负责读取主服务器的二进制日志,并将其保存为中继日志;另一个是SQL线程,复制执行中继日志。
1.6.5 Mysql的基础架构

Mysql逻辑架构图主要分三层:
(1)第一层负责连接处理,授权认证,安全等等
(2)第二层负责编译并优化SQL
(3)第三层是存储引擎。
web开发
1、Mybatis

首先,都知道jpa的实现是著名的ssh中的h——>Hibernate。对Hibernate的理解:一个不用写sql语句就可以操作数据库的框架。JPA是Java Persistence API的简称,中文名Java持久层API,是JDK 5.0注解或XML描述对象-关系表的映射关系,并将运行期的实体对象持久化到数据库中。jpa规范对于单表的简单查询确实简单方便又实用。
MyBatis 作为一个半ORM框架,MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。称Mybatis是半自动ORM映射工具,是因为在查询关联对象或关联集合对象时,需要手动编写sql来完成。不像Hibernate这种全自动ORM映射工具,Hibernate查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取。
1.1 谈谈MyBatis和JPA的区别
ORM映射不同:
-
MyBatis是半自动的ORM框架,提供数据库与结果集的映射;
-
JPA(默认采用Hibernate实现)是全自动的ORM框架,提供对象与数据库的映射。
可移植性不同:
-
JPA通过它强大的映射结构和HQL语言,大大降低了对象与数据库的耦合性;
-
MyBatis由于需要写SQL,因此与数据库的耦合性直接取决于SQL的写法,如果SQL不具备通用性而用了很多数据库的特性SQL的话,移植性就会降低很多,移植时成本很高。
日志系统的完整性不同:
-
JPA日志系统非常健全、涉及广泛,包括:SQL记录、关系异常、优化警告、缓存提示、脏数据警告等;
-
MyBatis除了基本的记录功能外,日志功能薄弱很多。
SQL优化上的区别:
-
由于Mybatis的SQL都是写在XML里,因此优化SQL比Hibernate方便很多。
-
而Hibernate的SQL很多都是自动生成的,无法直接维护SQL。虽有HQL,但功能还是不及SQL强大,见到报表等复杂需求时HQL就无能为力,也就是说HQL是有局限的Hhibernate虽然也支持原生SQL,但开发模式上却与ORM不同,需要转换思维,因此使用上不是非常方便。总之写SQL的灵活度上Hibernate不及Mybatis。
1.2 Mybaits的优缺点:
(1)优点:
① 基于SQL语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。
② 与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接;
③ 很好的与各种数据库兼容(因为MyBatis使用JDBC来连接数据库,所以只要JDBC支持的数据库MyBatis都支持)。
④ 能够与Spring很好的集成
⑤ 提供映射标签,支持对象与数据库的ORM字段关系映射;提供对象关系映射标签,支持对象关系组件维护。
(2)缺点:
① SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。
② SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。
1.3 MyBatis输入输出支持的类型有哪些?
parameterType:
MyBatis支持多种输入输出类型,包括:
-
简单的类型,如整数、小数、字符串等;
-
集合类型,如Map等;
-
自定义的JavaBean。
其中,简单的类型,其数值直接映射到参数上。对于Map或JavaBean则将其属性按照名称映射到参数上。
1.4 MyBatis中的$和#有什么区别?
使用#设置参数时,MyBatis会创建预编译的SQL语句,然后在执行SQL时MyBatis会为预编译SQL中的占位符(?)赋值。预编译的SQL语句执行效率高,并且可以防止注入攻击。使用$设置参数时,MyBatis只是创建普通的SQL语句,然后在执行SQL语句时MyBatis将参数直接拼入到SQL里。这种方式在效率、安全性上均不如前者,但是可以解决一些特殊情况下的问题。例如,在一些动态表格(根据不同的条件产生不同的动态列)中,我们要传递SQL的列名,根据某些列进行排序,或者传递列名给SQL都是比较常见的场景,这就无法使用预编译的方式了。
1.5 通常一个mapper.xml文件,都会对应一个Dao接口,这个Dao接口的工作原理是什么?Dao接口里的方法,参数不同时,方法能重载吗?
Mapper 接口的工作原理是JDK动态代理,Mybatis运行时会使用JDK动态代理为Mapper接口生成代理对象proxy,代理对象会拦截接口方法,根据类的全限定名+方法名,唯一定位到一个MapperStatement并调用执行器执行所代表的sql,然后将sql执行结果返回。
Mapper接口里的方法,是不能重载的,因为是使用 全限名+方法名 的保存和寻找策略。
Dao接口即Mapper接口。接口的全限名,就是映射文件中的namespace的值;接口的方法名,就是映射文件中Mapper的Statement的id值;接口方法内的参数,就是传递给sql的参数。
当调用接口方法时,接口全限名+方法名拼接字符串作为key值,可唯一定位一个MapperStatement。在Mybatis中,每一个SQL标签,比如
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!