Skip to content

YoungDriverOfTech/java-interview-note

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

关于面试的准备

  • 深度最重要,尤其是工作中接触的技术
  • 多面试,勤给自己不想去的公司投简历面试
  • 没接触过的就说没接触过,接触过的一定要了解的有深度
  • 简历一丝不苟,决定没有地基错误
  • 项目:技术点+效果+个人贡献(最重要)

1. Java基础

1. 什么是面向对象?

面向对象程序设计(英语:Object-oriented programming,缩写:OOP)是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):
所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。 class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型 而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同

2. 面向对象的三大特征?

Java 面向对象编程有三大特性:封装、继承、多态。

  • 封装(Encapsulation) 增强安全性和简化编程,使用者不必了解具体的实现细节,而只要通过对外公开的访问方法,来使用类的成员。 其基本要求是

    1. 把所有的属性私有化。
    2. 对每个属性提供 gettersetter 方法。
    3. 如果有一个带参的构造函数的话,那一定要写一个不带参的构造函数。
    4. 建议重写 toString 方法,但这不是必须的。
  • 继承(Inheritance) 可以理解为,在一个现有类的基础之上,增加新的方法或重写已有方法,从而产生一个新类。 我们在编写 Java 代码时,每一个类都是在继承。因为在 Java 中存在一个所有类的父类(基类、超类):java.lang.Object。 继承给我们的编程带来的好处就是对原有类的复用(重用)。除了继承之外,我们还可以使用组合的方式来复用类。

  • 多态(Polymorphism) 相同的事物,调用其相同的方法,参数也相同时,但表现的行为却不同。

3. JDK、JRE、JVM 三者之间的关系?

关系

  • jdk(Java Development Kit)
    Java 语言的软件开发工具包(SDK)。 它是每一个 Java 软件开发人员必须安装的。JDK 安装之后,它会自带一个 JRE,因为软件开发人员编写完代码之后总是要运行的。

  • jre(Java Runtime Environment,Java 运行环境)
    运行 JAVA程序所必须的环境的集合,包含 JVM 标准实现及 Java 核心类库。

  • JVM(Java Virtual Machine)
    JVM就是我们常说的java虚拟机,它是整个java实现跨平台的最核心的部分,所有的java程序会首先被编译为.class的类文件,这种类文件可以在虚拟机上执行,也就是说class并不直接与机器的操作系统相对应,而是经过虚拟机间接与操作系统交互,由虚拟机将程序解释给本地系统执行。
    可以理解为是一个虚拟出来的计算机,具备着计算机的基本运算方式,它主要负责将java程序生成的字节码文件解释成具体系统平台上的机器指令。让具体平台如window运行这些Java程序。

4. 重载和重写是什么,以及区别?

  • 重写 (Override)
    重写是子类对父类的允许访问的方法的实现过程进行重新编写!返回值和形参都不能改变。即外壳不变,核心重写!
    重写的好处在于子类可以根据需要,定义特定于自己的行为。
    也就是说子类能够根据需要实现父类的方法。
class Animal{

   public void move(){
      System.out.println("动物可以移动");
   }
}

class Dog extends Animal{

   public void move(){
      System.out.println("狗可以跑和走");
   }
}

public class TestDog{

   public static void main(String args[]){
      Animal a = new Animal(); // Animal 对象
      Animal b = new Dog(); // Dog 对象

      a.move();// 执行 Animal 类的方法

      b.move();//执行 Dog 类的方法
   }
}

// 运行结果
// 动物可以移动
// 狗可以跑和走
  • 重载
    重载 (overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型呢?可以相同也可以不同。 每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。 最常用的地方就是构造器的重载。
    重载规则
    • 被重载的方法必须改变参数列表;
    • 被重载的方法可以改变返回类型;
    • 被重载的方法可以改变访问修饰符;
    • 被重载的方法可以声明新的或更广的检查异常;
    • 方法能够在同一个类中或者在一个子类中被重载。
    • 无法以返回值类型作为重载函数的区分标准。
public class Overloading {
 
	public int test(){
		System.out.println("test1");
		return 1;
	}
 
	public void test(int a){
		System.out.println("test2");
	}	
 
	//以下两个参数类型顺序不同
	public String test(int a,String s){
		System.out.println("test3");
		return "returntest3";
	}	
 
	public String test(String s,int a){
		System.out.println("test4");
		return "returntest4";
	}	
 
	public static void main(String[] args){
		Overloading o = new Overloading();
		System.out.println(o.test());
		o.test(1);
		System.out.println(o.test(1,"test3"));
		System.out.println(o.test("test4",1));
	}
}
区别点 重载方法 重写方法
参数列表 必须修改 一定不能修改
返回类型 可以修改 一定不能修改
异常 可以修改 可以减少或删除,一定不能抛出新的或者更广的异常
访问 可以修改 一定不能做更严格的限制(可以降低限制)

5. Java 中是否可以重写一个 private 或者 static 方法?

  • 对于私有方法
    私有方法属于当前类私有,外部连调用都没法调用,子类重写则在编译阶段就会报错。

  • 对于静态方法

    • 静态方法是在编译时期绑定的。
    • 静态方法可以被继承但不可以被重写
    • 如果子类与父类存在同样的名称和参数的静态方法,则子类会把继承过来的父类静态方法给隐藏掉
    • 引用类型是什么,就调用哪个类的静态方法。

6. 构造方法有哪些特性?

// 构造函数是在新建类时会执行的程序
Duck duck = new Duck();

// 构造函数必须与类的名字一样,且没有返回类型
// 如果没有构造函数,编译器会帮你写一个无参构造函数
public Duck(){

}

// 构造函数可以重载(overload)
public Duck(){}
public Duck(int i){}
public Duck(String i){}

// 在创建新对象时,所有继承下来的构造函数(每个父类类至少一个构造函数)都会执行Object->Animal->Duck(构造函数链)

// 调用父类构造函数的唯一方法是调用super()
public ClassNmae() {
    super();    // call parent class constructor
}

7. 在 Java 中定义一个不做事且没有参数的构造方法有什么作用?

Java 程序在执行子类的构造方法之前,如果没有用 super() 来调用父类特定的构造方法,则会调用父类中“没有参数的构造方法”。因此,如果父类中只定 义了有参数的构造方法,而在子类的构造方法中又没有用 super() 来调用父类 中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没 有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数 的构造方法。

8. Java 中创建对象的几种方式?

  • new关键字
    这是我们最常见的也是最简单的创建对象的方式,通过这种方式我们还可以调用任意的构造器(无参的和有参的)。
Person person1 = new Person();
Person person2 = new Person("fsx", 18);
  • Class.newInstance
    使用反射,Class类的newInstance使用的是类的public的无参构造器。因此也就是说使用此方法创建对象的前提是必须有public的无参构造器才行,否则报错
Person person = Person.class.newInstance();
System.out.println(person); // Person{name='null', age=null}
  • Constructor.newInstance
    本方法和Class类的newInstance方法很像,但是比它强大很多。 java.lang.relect.Constructor类里也有一个newInstance方法可以创建对象。我们可以通过这个newInstance方法调用有参数(不再必须是无参)的和私有的构造函数(不再必须是public)。
// 包括public的和非public的,当然也包括private的
Constructor<?>[] declaredConstructors = Person.class.getDeclaredConstructors();
// 只返回public的~~~~~~(返回结果是上面的子集)
Constructor<?>[] constructors = Person.class.getConstructors();


Constructor<?> noArgsConstructor = declaredConstructors[0];
Constructor<?> haveArgsConstructor = declaredConstructors[1];

noArgsConstructor.setAccessible(true); // 非public的构造必须设置true才能用于创建实例
Object person1 = noArgsConstructor.newInstance();
Object person2 = declaredConstructors[1].newInstance("fsx", 18);

System.out.println(person1);
System.out.println(person2);
  • Clone
    无论何时我们调用一个对象的clone方法,JVM就会创建一个新的对象,将前面的对象的内容全部拷贝进去,用clone方法创建对象并不会调用任何构造函数。
    要使用clone方法,我们必须先实现Cloneable接口并复写Object的clone方法(因为Object的这个方法是protected的,你若不复写,外部也调用不了呀)。
public class Person implements Cloneable {
	...
	// 访问权限写为public,并且返回值写为person
    @Override
    public Person clone() throws CloneNotSupportedException {
        return (Person) super.clone();
    }
    ...
}

public class Main {

    public static void main(String[] args) throws Exception {
        Person person = new Person("fsx", 18);
        Object clone = person.clone();

        System.out.println(person);
        System.out.println(clone);
        System.out.println(person == clone); //false
    }

}
  • 反序列化
    当我们序列化和反序列化一个对象,JVM会给我们创建一个单独的对象,在反序列化时,JVM创建对象并不会调用任何构造函数。
    为了反序列化一个对象,我们需要让我们的类实现Serializable接口。
public class Main {

    public static void main(String[] args) throws Exception {
        Person person = new Person("fsx", 18);
        byte[] bytes = SerializationUtils.serialize(person);

        // 字节数组:可以来自网络、可以来自文件(本处直接本地模拟)
        Object deserPerson = SerializationUtils.deserialize(bytes);
        System.out.println(person);
        System.out.println(deserPerson);
        System.out.println(person == deserPerson);
    }

}

9. 抽象类和接口有什么区别?

本身的设计目的就是不同的。大家讲的都很详细了,我说说我自己的一点浅薄的理解。
我一直认为,工科的知识有个很明显的特点:“以用为本”。在讨论接口和抽象类的区别时,我也想从“用”的角度试着总结一下区别,所以我想到了设计目的。

接口的设计目的,是对类的行为进行约束(更准确的说是一种“有”约束,因为接口不能规定类不可以有什么行为),也就是提供一种机制,可以强制要求不同的类具有相同的行为。它只约束了行为的有无,但不对如何实现行为进行限制。对“接口为何是约束”的理解,我觉得配合泛型食用效果更佳。

而抽象类的设计目的,是代码复用。当不同的类具有某些相同的行为(记为行为集合A),且其中一部分行为的实现方式一致时(A的非真子集,记为B),可以让这些类都派生于一个抽象类。在这个抽象类中实现了B,避免让所有的子类来实现B,这就达到了代码复用的目的。而A减B的部分,留给各个子类自己实现。正是因为A-B在这里没有实现,所以抽象类不允许实例化出来(否则当调用到A-B时,无法执行)。

作者:阿法利亚 链接:https://www.zhihu.com/question/20149818/answer/150169365 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

10. 静态变量和实例变量的区别?

public class StaticTest {
    private static int staticInt = 2;
    private int instanceInt = 2;

    public StaticTest() {
        this.staticInt++;
        this.instanceInt++;

        System.out.print(this.staticInt + " " + this.instanceInt);
    }

    public static void main(String[] args) {
        new StaticTest();
        new StaticTest();

        // 打印出
        // 3 3
        // 4 3
    }
}

实例变量属于某个对象的属性,必须创建了实例对象,其中的实例变量才会被分配空间,才能使用这个实例变量。结合上述给出的例子。每创建一个实例对象,就会分配一个random,实例对象之间的random是互不影响的,所以就可以解释为什么输出的两个random值是相同的了。

静态变量不属于某个实例对象,而是属于整个类。只要程序加载了类的字节码,不用创建任何实例对象,静态变量就回被分配空间,静态变量就可以被使用了。结合上述给出的例子,无论创建多少个实例对象,永远都只分配一个staticInt 变量,并且每创建一个实例对象,staticInt就会加一。

总之,实例变量必须创建对象后,才可以通过这个对象来使用;静态变量则可以直接使用类名来引用(如果实例对象存在,也可以通过实例对象来引用)。

11. short s1 = 1;s1 = s1 + 1;有什么错?那么 short s1 = 1; s1 += 1;呢?有没有错误?

Java规范有这样的规则:
高位转低位需要强制转换 低位转高位自动转.

// s1是short型,1是short型,通过+运算符,计算的时候s1转换为int型,最后把s1+1赋值给s1的时候,s1是short型,所以出错。
short s1 = 1;
s1 = s1 + 1;  

// 复合赋值表达式自动地将所执行计算的结果转型为其左侧变量的类型。如果结果的类型与该变量的类型相同,那么这个转型不会造成任何影响。
short s2 = 1;
s2 += 1;

12. Integer 和 int 的区别?

基本对比

  1. Integer是int的包装类;int是基本数据类型;
  2. Integer变量必须实例化后才能使用;int变量不需要;
  3. Integer实际是对象的引用,指向此new的Integer对象;int是直接存储数据值 ;
  4. Integer的默认值是null;int的默认值是0。

深入对比

  1. 由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。
Integer i = new Integer(100);
Integer j = new Integer(100);
System.out.print(i == j); //false
  1. Integer变量和int变量比较时,只要两个变量的值是向等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)
Integer i = new Integer(100);
int j = 100System.out.print(i == j); //true
  1. 非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。(因为非new生成的Integer变量指向的是java常量池中的对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的地址不同)
Integer i = new Integer(100);
Integer j = 100;
System.out.print(i == j); //false
  1. 对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false
Integer i = 100;
Integer j = 100;
System.out.print(i == j); //true

Integer i = 128;
Integer j = 128;
System.out.print(i == j); //false

/**
对于第4条的原因: java在编译Integer i = 100 ;时,会翻译成为Integer i = Integer.valueOf(100)。而java API中对Integer类型的valueOf的定义如下,对于-128到127之间的数,会进行缓存,Integer i = 127时,会将127进行缓存,下次再写Integer j = 127时,就会直接从缓存中取,就不会new了。
*/
public static Integer valueOf(int i){
    assert IntegerCache.high >= 127;
    if (i >= IntegerCache.low && i <= IntegerCache.high){
        return IntegerCache.cache[i + (-IntegerCache.low)];
    }
    return new Integer(i);
}

13. 装箱和拆箱的区别?

自动装箱:将基本数据类型重新转化为对象

 //声明一个Integer对象
Integer num = 9;

// 以上的声明就是用到了自动的装箱:解析为:Integer num = new Integer(9);
// 9是属于基本数据类型的,原则上它是不能直接赋值给一个对象Integer的,但jdk1.5后你就可以进行这样的声明。自动将基本数据类型转化为对应的封装类型,成为一个对象以后就可以调用对象所声明的所有的方法。

自动拆箱:将对象重新转化为基本数据类型

//声明一个Integer对象
Integer num = new Integer(9);

//进行计算时隐含的有自动拆箱
System.out.print(num--);

// 因为对象时不能直接进行运算的,而是要转化为基本数据类型后才能进行加减乘除。对比:

// 装箱 Integer num = 10; 
// 拆箱 int num1 = num;

深入解析

//在-128~127 之外的数
Integer num1 = 128;   Integer num2 = 128;           
System.out.println(num1==num2);   //false

// 在-128~127 之内的数 
Integer num3 = 9;   Integer num4 = 9;   
System.out.println(num3==num4);   //true

解析原因:归结于java对于Integer与int的自动装箱与拆箱的设计,是一种模式:叫享元模式(flyweight)。加大对简单数字的重利用,Java定义在自动装箱时对于值从–128到127之间的值,它们被装箱为Integer对象后,会存在内存中被重用,始终只存在一个对象。而如果超过了从–128到127之间的值,被装箱后的Integer对象并不会被重用,即相当于每次装箱时都新建一个 Integer对象。

14. switch 语句能否作用在 byte 上,能否作用在 long 上,能否作用在 String 上?

/**
 * 问题:switch语句能否作用在byte上,能否作用在long上,能否作用在String上
 * 基本类型的包装类(如:Character、Byte、Short、Integer)
 *
 * switch可作用于char byte short int
 * switch可作用于char byte short int对应的包装类
 * switch不可作用于long double float boolean,包括他们的包装类
 * switch中可以是字符串类型,String(jdk1.7之后才可以作用在string上)
 * switch中可以是枚举类型


 在switch(expr1)中,expr1只能是一个整数表达式或者枚举常量(更大字体),整数表达式可以是int基本类型或Integer包装类型,由于,byte,short,char都可以隐含转换为int,所以,这些类型以及这些类型的包装类型也是可以的。显然,long和String类型都不符合switch的语法规定,并且不能被隐式转换成int类型,所以,它们不能作用于swtich语句中。但是在JDK7的新特性中,switch语句可以用字符串。
 */

15. final、finally、finalize 的区别

final

  1. 修饰符(关键字) 如果一个类被声明为final,意味着它不能再派生新的子类,不能作为父类被继承。 【因此一个类不能及被声明为abstract,又被声明为final的。】

  2. 将变量或方法声明为final,可以保证他们使用中不被改变。被声明为final的变量必须在声明时给定初值,而以后的引用中只能读取,不可修改,被声明为final的方法也同样只能使用,不能重载。

2. finally

  1. 在异常处理时提供finally块来执行清楚操作。如果抛出一个异常,那么相匹配的catch语句就会执行,然后控制就会进入finally块,如果有的话。

3. finalize

  1. 是方法名。java技术允许使用finalize()方法在垃圾收集器将对象从内存中清除之前做必要的清理工作。这个方法是在垃圾收集器在确定了,被清理对象没有被引用的情况下调用的。 finalize是在Object类中定义的,因此,所有的类都继承了它。子类可以覆盖finalize()方法,来整理系统资源或者执行其他清理工作。

16. == 和 equals 的区别?

== 解读

对于基本类型和引用类型 == 的作用效果是不同的,如下所示:

  • 基本类型:比较的是值是否相同;
  • 引用类型:比较的是引用是否相同;
String x = "string";
String y = "string";
String z = new String("string");
System.out.println(x==y); // true
System.out.println(x==z); // false
System.out.println(x.equals(y)); // true
System.out.println(x.equals(z)); // true

代码解读:因为 x 和 y 指向的是同一个引用,所以 == 也是 true,而 new String()方法则重写开辟了内存空间,所以 == 结果为 false,而 equals 比较的一直是值,所以结果都为 true。

equals 解读

equals 本质上就是 ==,只不过 String 和 Integer 等重写了 equals 方法,把它变成了值比较。看下面的代码就明白了。
首先来看默认情况下 equals 比较一个有相同值的对象,代码如下:

class Cat {
    public Cat(String name) {
        this.name = name;
    }

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Cat c1 = new Cat("王磊");
Cat c2 = new Cat("王磊");
System.out.println(c1.equals(c2)); // false

// 输出结果出乎我们的意料,竟然是 false?这是怎么回事,看了 equals 源码就知道了,源码如下:
public boolean equals(Object obj) {
		return (this == obj);
}

// 原来 equals 本质上就是 ==。
// 那问题来了,两个相同值的 String 对象,为什么返回的是 true?代码如下:
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

// 原来是 String 重写了 Object 的 equals 方法,把引用比较改成了值比较。

// 总体来说,== 对于基本类型来说是值比较,对于引用类型来说是比较的是引用;而 equals 默认情况下是引用比较,只是很多类重写了 equals 方法,比如 String、Integer 等把它变成了值比较,所以一般情况下 equals 比较的是值是否相等。

17&18. 两个对象的 hashCode() 相同,则 equals() 也一定为 true 吗?为什么重写 equals() 就一定要重写 hashCode() 方法?

equals

在初学Java的时候,很多人会说在比较对象的时候,==是比较地址,equals()是比较对象的内容,谁说的?

看看equals()方法在Object类中的定义:

public boolean equals(Object obj){
    return (this == obj);
}

上面代码明显是比较指针(地址)…
但是为什么会有equals是比较内容的这种说法呢?

因为在String、Double等封装类中,已经重载(overriding)了Object类的equals()方法,于是有了另一种计算公式,是进行内容的比较。

比如在String类中:

 public int hashCode() { 
    int h = hash; 
    if (h == 0) { 
        char val[] = value; 
        int len = count; 
        for (int i = 0; i < len; i++) { 
            h = 31*h + val[off++]; 
        } 
        hash = h; 
    } 
    return h; 
} 

hashCode

在Object类中的定义为:

public native int hashCode();

是一个本地方法,返回的对象的地址值。
但是,同样的思路,在String等封装类中对此方法进行了重写。方法调用得到一个计算公式得到的 int值.

hashCode和equlas的关系

1、如果两个对象相同(即用equals比较返回true),那么它们的hashCode值一定要相同;
2、如果两个对象的hashCode相同,它们并不一定相同(即用equals比较返回false) 原因:从散列的角度考虑,不同的对象计算哈希码的时候,可能引起冲突,大家一定还记得数据结构中。

理解:由于为了提高程序的效率才实现了hashcode方法,先进行hashcode的比较,如果不同,那没就不必在进行equals的比较了,这样就大大减少了equals比较的 次数,这对比需要比较的数量很大的效率提高是很明显的,一个很好的例子就是在集合中的使用;

我们都知道java中的List集合是有序的,因此是可以重复的,而set集合是无序的,因此是不能重复的,那么怎么能保证不能被放入重复的元素呢,但靠equals方法一样比较的 话,如果原来集合中以后又10000个元素了,那么放入10001个元素,难道要将前面的所有元素都进行比较,看看是否有重复,这个效率可想而知.

因此hashcode 就应遇而生了,java就采用了hash表,利用哈希算法(也叫散列算法),就是将对象数据根据该对象的特征使用特定的算法将其定义到一个地址上,那么在后面定义进来的数据 只要看对应的hashcode地址上是否有值,那么就用equals比较,如果没有则直接插入,只要就大大减少了equals的使用次数,执行效率就大大提高了。

继续上面的话题,为什么必须要重写hashcode方法,其实简单的说就是为了保证同一个对象,保证在equals相同的情况下hashcode值必定相同,如果重写了equals而未重写 hashcode方法,可能就会出现两个没有关系的对象equals相同的(因为equal都是根据对象的特征进行重写的),但hashcode确实不相同的。

19. & 和 && 的区别?

相同点

&和&&都可以用作逻辑与的运算符,表示逻辑与(and)。

不同点

  1. &&具有短路的功能,而&不具备短路功能。
  2. 二者都要求运算符左右两端的布尔值都是true 整个表达式的值才是 true。&&之所以称为短路与运算,是因为如果&&左边的表达式的值是 false,右边的表达式会被直接短路掉,不会进行运算。而&就算左边的表达式为false,也会计算右边的表达式。
  3. &还可以用作位运算符,当&操作符两边的表达式不是boolean类型时,&表示按位与操作,我们通常使用0x0f来与一个整数进行&运算,来获取该整数的最低4个bit位,例如:0x31 & 0x0f的结果为0x01。

20. Java 中的参数传递时传值呢?还是传引用?

Java里面只有值传递,没有引用传递,所谓的引用传递只是复制了一份内存地址的值而已

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("11");
    System.out.println(list); // [11]
    change(list);
    System.out.println(list); // [11]

}

private static void change(List<String> list) {
    list = new ArrayList<>();
    list.add("222");
}

21. Java 中的 Math.round(-1.5) 等于多少?

方法 说明
static double ceil(double a) 返回大于或等于 a 的最小整数
static double floor(double a) 返回小于或等于 a 的最大整数
static double rint(double a) 返回最接近 a 的整数值,如果有两个同样接近的整数,则结果取偶数
static int round(float a) 将参数加上 1/2 后返回与参数最近的整数
static long round(double a) 将参数加上 1/2 后返回与参数最近的整数,然后强制转换为长整型
public static void main(String[] args) {
    Scanner input = new Scanner(System.in);
    System.outprintln("请输入一个数字:");
    double num = input.nextDouble();
    System.out.println("大于或等于 "+ num +" 的最小整数:" + Math.ceil(num));
    System.out.println("小于或等于 "+ num +" 的最大整数:" + Math.floor(num));
    System.out.println("将 "+ num +" 加上 0.5 之后最接近的整数:" + Math.round(num));
    System.out.println("最接近 "+num+" 的整数:" + Math.rint(num));
}

/*
请输入一个数字:
99.01
大于或等于 99.01 的最小整数:100.0
小于或等于 99.01 的最大整数:99.0
将 99.01 加上 0.5 之后最接近的整数:100
最接近 99.01 的整数:99.0
*/

// -1.5+0.5=-1.0,然后-1.0向下取整结果为-1,最后的结果就是-1
// 问题的点就是计算规则中的+0.5,如果将-1.5换成+1.5的话结果就是
// 1.5+0.5=2.0,然后2.0向下取整结果为2

22&23. 如何实现对象的克隆(Todo)? 深克隆和浅克隆的区别?

浅克隆

  1. 在要实现克隆的对象类中实现Cloneable接口。

Cloneable接口为标记接口(标记接口为用户标记实现该接口的类具有该接口标记的功能,常见的标记接口有Serializable、Cloneable、RandomAccess),如果没有实现该接口,在调用clone方法时就会抛出CloneNotSupportException异常。

  1. 在类中重写Object的clone方法。

重写是为了扩大访问权限,如果不重写,因Object的clone方法的修饰符是protected,除了与Object同包(java.lang)和直接子类能访问,其他类无权访问。并且默认Object的clone表现出来的是浅拷贝,如果要实现深拷贝,也是需要重写该方法的。

  1. 代码
    浅拷贝的效果是实力对象中的引用属性,无法被深拷贝
public class Child implements Cloneable{
    public int age;
    public Person person;

    public Child(int age, Person person) {
        this.age = age;
        this.person = person;
    }

    @Override
    public Child clone() {
        try {
            Child clone = (Child) super.clone();
            // TODO: copy mutable state here, so the clone can't change the internals of the original
            return clone;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }


    // 省略get/set/toString
    public static void main(String[] args) {
        Person person = new Person("zhangsan");
        Child child = new Child(10, person);
        Child newChild = child.clone();

        // 属性改变之前
        System.out.println("child = " + child);
        System.out.println("newChild = " + newChild);

        // 属性改变之后
        child.age = 15;
        child.person.name = "lisi";
        System.out.println("child = " + child);
        System.out.println("newChild = " + newChild);
        
        // 输出
        // 引用属性无法被深拷贝
        // child = Child{age=10, person=Person{name='zhangsan'}}
        // newChild = Child{age=10, person=Person{name='zhangsan'}}

        // child = Child{age=15, person=Person{name='lisi'}}
        // newChild = Child{age=10, person=Person{name='lisi'}}
    }
}


public class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }

    // 省略get/set/toString
}

深拷贝

让实例里面的引用属性也实现Cloneable接口,这样就能实现深拷贝了

public class Person  implements Cloneable{
    public String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    public Person clone() {
        try {
            Person clone = (Person) super.clone();
            // TODO: copy mutable state here, so the clone can't change the internals of the original
            return clone;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Child implements Cloneable{
    @Override
    public Child clone() {
        try {
            Child clone = (Child) super.clone();
            clone.person = this.person.clone();     // Child类里面追加这行代码
            return clone;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

24&25. 什么是 Java 的序列化,如何实现 Java 的序列化? 什么情况下需要序列化?

简单解释

序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。

序列化是为了解决在对对象流进行读写操作时所引发的问题。序列化的实现:将需要被序列化的类实现Serializable接口,该接口没有需要实现的方法,implements Serializable只是为了标注该对象是可被序列化的, 然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream(对象流)对象,接着,使用ObjectOutputStream对象的writeObject(Object obj)方法就可以将参数为obj的对象写出(即保存其状态),要恢复的话则用输入流。

详细解释

当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。 只能将支持 java.io.Serializable 接口的对象写入流中。每个 serializable 对象的类都被编码,编码内容包括类名和类签名、对象的字段值和数组值,以及从初始对象中引用的其他所有对象的闭包。

  1. 概念
      序列化:把Java对象转换为字节序列的过程。
      反序列化:把字节序列恢复为Java对象的过程。

  2. 用途
      对象的序列化主要有两种用途:
      1) 把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中;
      2) 在网络上传送对象的字节序列。

26. Java 的泛型是如何工作的 ? 什么是类型擦除 ?

泛型

所谓泛型,就是指在定义一个类、接口或者方法时可以指定类型参数。这个类型参数我们可以在使用类、接口或者方法时动态指定。 使用泛型可以给我们带来如下的好处:

  • 编译时类型检查:当我们使用泛型时,加入向容器中存入非特定对象在编译阶段就会报错。假如不使用泛型,可以向容器中存入任意类型,容易出现类型转换异常。
  • 不需要进行类型强制转换:使用泛型后容器可以记住存入容器中的对象的类型;
  • 代码可读性提升:使用泛型后开发人员看一眼就知道容器中存放的是何种对象。

泛型擦除

泛型擦除是指Java中的泛型只在编译期有效,在运行期间会被删除。也就是说所有泛型参数在编译后都会被清除掉。 泛型信息只存在于代码编译阶段,在进入 JVM 之前,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除。在泛型类被类型擦除的时候,之前泛型类中的类型参数部分如果没有指定上限,如 < T > 则会被转译成普通的 Object 类型,如果指定了上限如 < T extends String > 则类型参数就被替换成类型上限。

27. 什么是泛型中的限定通配符和非限定通配符 ?

有两种限定通配符,一种是它通过确保类型必须是T的子类来设定类型的上界,另一种是它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。另一方面表示了非限定通配符,因为可以用任意类型来替代。

即:限定通配符包括两种:

  1. 表示类型的上界,格式为:<? extends T>,即类型必须为T类型或者T子类
  2. 表示类型的下界,格式为:<? super T>,即类型必须为T类型或者T的父类

非限定通配符:类型为,可以用任意类型来替代。

28. List 和 List 之间有什么区别 ?

  • list 有序可重复
  • set 无序不可重复

29. Java 中的反射是什么意思?有哪些应用场景?

概念

反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。

应用场景

Spring IOC容器

30. 反射的优缺点?

优点

  1. 增加程序的灵活性,避免将程序写死到代码里。
  2. 代码简洁,提高代码的复用率,外部调用方便
  3. 对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法

缺点

  1. 性能问题
    使用反射基本上是一种解释操作,用于字段和方法接入时要远慢于直接代码。因此Java反射机制主要应用在对灵活性和扩展性要求很高的系统框架上,普通程序不建议使用。
    反射包括了一些动态类型,所以JVM无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。我们应该避免在经常被 执行的代码或对性能要求很高的程序中使用反射。

  2. 使用反射会模糊程序内部逻辑
    程序人员希望在源代码中看到程序的逻辑,反射等绕过了源代码的技术,因而会带来维护问题。反射代码比相应的直接代码更复杂。

  3. 安全限制
    使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如Applet,那么这就是个问题了。

  4. 内部暴露
    由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用--代码有功能上的错误,降低可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。

31&32. Java 中的动态代理是什么?有哪些应用?怎么实现动态代理?

JDK动态代理
CDLib动态代理
AOP面向切面编程

33. static 关键字的作用?

static关键字基本概念

也就是说:被static关键字修饰的不需要创建对象去调用,直接根据类名就可以去访问

static关键字修饰类

java里面static一般用来修饰成员变量或函数。但有一种特殊用法是用static修饰内部类,普通类是不允许声明为静态的,只有内部类才可以。下面看看如何使用。

public class StaticTest {
    //static关键字修饰内部类
    public static class InnerClass{
        InnerClass(){
            System.out.println("============= 静态内部类=============");
        }
        public void InnerMethod() {
            System.out.println("============= 静态内部方法=============");
        }
    }
    public static void main(String[] args) {
        //直接通过StaticTest类名访问静态内部类InnerClass
        InnerClass inner=new StaticTest.InnerClass();
        //静态内部类可以和普通类一样使用
        inner.InnerMethod();
    }
}
/*  输出是
 * ============= 静态内部类=============
 * ============= 静态内部方法=============
 */

 // 如果没有用static修饰InterClass,则只能new 一个外部类实例。再通过外部实例创建内部类。

static关键字修饰方法

修饰方法的时候,其实跟类一样,可以直接通过类名来进行调用:

public class StaticMethod {
    public static void test() {
        System.out.println("============= 静态方法=============");
    };
    public static void main(String[] args) {
        //方式一:直接通过类名
        StaticMethod.test();
        //方式二:
        StaticMethod fdd=new StaticMethod();
        fdd.test();
    }
}

static关键字修饰变量

被static修饰的成员变量叫做静态变量,也叫做类变量,说明这个变量是属于这个类的,而不是属于是对象,没有被static修饰的成员变量叫做实例变量,说明这个变量是属于某个具体的对象的。

我们同样可以使用上面的方式进行调用变量:

public class StaticVar {
    private static String name="java的架构师技术栈"public static void main(String[] args) {
        //直接通过类名
        StaticVar.name;
    }
}

static关键字修饰代码块

  • 父类静态变量
  • 父类静态代码块
  • 子类静态变量
  • 子类静态代码块
  • 父类普通变量
  • 父类普通代码块
  • 父类构造函数
  • 子类普通变量
  • 子类普通代码块
  • 子类构造函数

深入分析static

要理解static为什么会有上面的特性,首先我们还需要从jvm内存说起。我们先给出一张java的内存结构图,然后通过案例描述一下static修饰的变量存放在哪?
jvm内存图
从上图我们可以发现,静态变量存放在方法区中,并且是被所有线程所共享的。这里要说一下java堆,java堆存放的就是我们创建的一个个实例变量。

堆区:

1、存储的全部是对象,每个对象都包含一个与之对应的class的信息。(class的目的是得到操作指令)
2、jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身

栈区:

1.每个线程包含一个栈区,栈中只保存基础数据类型的对象和自定义对象的引用(不是对象),对象都存放在堆区中
2、每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
3、栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。

方法区:

1、又叫静态区,跟堆一样,被所有的线程共享。方法区包含所有的class和static变量。
2、方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。

下面通过一个案例说明一下,从内存的角度来看,static关键字为什么会有这样的特性。
首先我们定义一个类

public class Person {
    //静态变量
    static String firstName;
    String lastName;
    public void showName(){
        System.out.println(firstName+lastName);
    }
    //静态方法
    public static void viewName(){
      System.out.println(firstName);
    }
    
    public static void main(String[] args) {
        Person p =new Person();
        Person.firstName = "张";
        p.lastName="三";
        p.showName();
        
        Person p2 =new Person();
        Person.firstName="李";
        p2.lastName="四";
        p2.showName();
    }
}
//输出。张三、李四

jvm内存图
从上面可以看到,我们的方法在调用的时候,是从方法区调用的,但是堆内存不一样,堆内存中的成员变量lastname是随着对象的产生而产生。随着对象的消失而消失。静态变量是所有线程共享的,所以不会消失。这也就能解释上面static关键字的真正原因。

34. super 关键字的作用?

  1. super关键字可以在子类的构造方法中显示地调用父类的构造方法,super()必须为子类构造函数中的第一行。

  2. super可以用来访问父类的成员方法或变量,当子类的方法或成员变量与父类有相同的名字时也会覆盖父类的成员变量或方法,这个时候要想访问父类的成员变量或方法只能通过super关键字来访问,子类方法中的super.方法名()不需要位于第一行。

35. 字节和字符的区别?

  1. byte(字节):

byte即字节的意思,是java中的基本数据类型,用来申明字节型的变量,一个字节包含8个位,所以,byte类型的取值范围是-128到127。

通常在读取非文本文件时(如图片,声音,可执行文件)需要用字节数组来保存文件的内容,在下载文件时,也是用byte数组作临时的缓冲器接收文件内容,所以说byte在文件操作时是必不可少的。

在某些程序中(尤其是和硬件有关的程序)会将某些数据存储到字节类型的变量中,比如00110010,其中每个位都代表一个参数,然后以位运算的方式对参数进行取值和赋值操作。

  1. 字符:

机器只知道字节,而字符却是语义上的单位,它是有编码的,一个字符可能编码成1个2个甚至3个4个字节。这跟字符集编码有关系,英文字母和数字是单字节,但汉字这些自然语言中的字符是多字节的。一个字节只能表示255个字符,不可能用于全球那么多种自然语言的处理,因此肯定需要多字节的存储方式。

那么在文件的输入输出中,InputStream、OutputStream它们是处理字节流的,就是说假设所有东西都是二进制的字节;而 Reader, Writer 则是字符流,它涉及到字符集的问题;按照ANSI编码标准,标点符号、数字、大小写字母都占一个字节,汉字占2个字节。按照UNICODE标准所有字符都占2个字节。

36. String 为什么要设计为不可变类?

  • 便于实现字符串池(String pool)

在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。

  • 使多线程安全
public class test {
  // 不可变的String
  public static String appendStr(String s) {
      s += "bbb";
      return s;
  }

  // 可变的StringBuilder
  public static StringBuilder appendSb(StringBuilder sb) {
      return sb.append("bbb");
  }
  
  public static void main(String[] args) {
      String s = new String("aaa");
      String ns = test.appendStr(s);
      System.out.println("String aaa>>>" + s.toString());
      // StringBuilder做参数
      StringBuilder sb = new StringBuilder("aaa");
      StringBuilder nsb = test.appendSb(sb);
      System.out.println("StringBuilder aaa >>>" + sb.toString());
  }
}

如果程序员不小心像上面例子里,直接在传进来的参数上加上“bbb”.因为Java对象参数传的是引用,所有可变的StringBuffer参数就被改变了。可以看到变量sb在Test.appendSb(sb)操作之后,就变成了"aaabbb"。 而不可变的s,即使在方法中给它加上bbb,他原本的s也不会变成aaabbb
有的时候这可能不是程序员的本意。所以String不可变的安全性就体现在这里。
在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。

  • 避免安全问题

在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值,那么会引起很严重的安全问题。

因为String是不可变的,所以它的值是不可改变的。但由于String不可变,也就没有任何方式能修改字符串的值,每一次修改都将产生新的字符串,如果使用char[]来保存密码,仍然能够将其中所有的元素设置为空和清零,也不会被放入字符串缓存池中,用字符串数组来保存密码会更好。

  • 加快字符串处理速度

由于String是不可变的,保证了hashcode的唯一性,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
在String类的定义中有如下代码:

private int hash;//用来缓存HashCode

37. String、StringBuilder、StringBuffer 的区别?

概念

用来处理字符串常用的类有3种:String、StringBuffer和StringBuilder
三者之间的区别:

  • 都是final类,都不允许被继承;
  • String类长度是不可变的,StringBuffer和StringBuilder类长度是可以改变的;
  • StringBuffer类是线程安全的,StringBuilder不是线程安全的;

String 和 StringBuffer

  • String类型和StringBuffer类型的主要性能区别:String是不可变的对象,因此每次在对String类进行改变的时候都会生成一个新的string对象,然后将指针指向新的string对象,所以经常要改变字符串长度的话不要使用string,因为每次生成对象都会对系统性能产生影响,特别是当内存中引用的对象多了以后,JVM的GC就会开始工作,性能就会降低;

  • 使用StringBuffer类时,每次都会对StringBuffer对象本身进行操作,而不是生成新的对象并改变对象引用,所以多数情况下推荐使用StringBuffer,特别是字符串对象经常要改变的情况;

  • 在某些情况下,String对象的字符串拼接其实是被Java Compiler编译成了StringBuffer对象的拼接,所以这些时候String对象的速度并不会比StringBuffer对象慢,例如:

String s1 = “This is only a” + “ simple” + “ test”;

StringBuffer Sb = new StringBuilder(“This is only a”).append(“ simple”).append(“ test”);

StringBuilder

StringBuilder是5.0新增的,此类提供一个与 StringBuffer 兼容的 API,但不保证同步。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。如果可能,建议优先采用该类,因为在大多数实现中,它比 StringBuffer 要快。两者的方法基本相同

使用策略

1. 基本原则:如果要操作少量的数据,用String ;单线程操作大量数据,用StringBuilder ;多线程操作大量数据,用StringBuffer。

2. 不要使用String类的”+”来进行频繁的拼接,因为那样的性能极差的,应该使用StringBuffer或StringBuilder类,这在Java的优化上是一条比较重要的原则,例如:

3. StringBuilder一般使用在方法内部来完成类似”+”功能,因为是线程不安全的,所以用完以后可以丢弃。StringBuffer主要用在全局变量中

4. 相同情况下使用 StirngBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。而在现实的模块化编程中,负责某一模块的程序员不一定能清晰地判断该模块是否会放入多线程的环境中运行,因此:除非确定系统的瓶颈是在 StringBuffer 上,并且确定你的模块不会运行在多线程模式下,才可以采用StringBuilder;否则还是用StringBuffer

38. final 修饰 StringBuffer 后还可以 append 吗?

可以,final 修饰的是一个引用变量,那么这个引用始终指向这个对象,但这个对象内部的属性是可以改变的。

2. 异常

1. finally 块中的代码什么时候被执行?

当try{}有return语句,finally{}有return语句

public class TestFinally
{

    public static int testFinally1()
    {
        try{
            return 1;
        }catch(Exception e){
            return 0;
        }finally{
            System.out.println("execute finally1");
            return 3;
        }
    }
    public static void main(String[] args) 
    {
        int result = testFinally1();
        System.out.println(result);     
    }
}

// 结果

D:\JavaProject\demo15>javac TestFinally.java

D:\JavaProject\demo15>java TestFinally
execute finally1
3
  1. 如果在try{}语句块中有return语句,而finally{}语句块中没有return语句时

finally{}块中的代码在return语句前执行。 因为:由于程序执行return语句就以为着结束对当前函数的调用并跳出这个函数体,因此任何语句要执行都只能在return前执行,因此finally块里代码也是在return前执行的。

  1. 如果在try{}语句块和finally语句块都有return语句时

finally语句块中的return语句将会覆盖函数中其他return语句。

finally{}中有修改return语句中的返回值

  • 修改的返回值为基本数据类型
public class TestFinally
{
public static int testFinally2()
    {
        int result = 1;
        try{
            result = 2;
            return result;//执行到此处,返回值被复制
        }catch(Exception e){
            return 0;
        }finally{
            result = 3;//改变result的值与复制result的值谁先谁后?
            /*从执行结果来看,是复制result的值在先,修改result的值在后。
            也就是在遇到return时,就对result进行了复制,然而,
            前面讲到“finally块里代码也是在return前执行的”, 
            即使在return前面执行finally语句块,但是在执行finally语句块
            时已经对返回值result进行了复制。*/
            System.out.println("execute finally2");
    }
    public static void main(String[] args) 
    {
        int resultVal = testFinally2();
        System.out.println(resultVal);      
    }
}


// 结果
D:\JavaProject\demo15>javac TestFinally.java

D:\JavaProject\demo15>java TestFinally
execute finally2
2

总结:程序执行到return时首先将返回值存储到一个指定的位置,其次去执行finally块,最后再返回。
对于基本类型的数据,如果在finally块中没有return语句而存在修改返回值的语句时,在finally块中改变return的值对返回值没有任何影响。

因为:在方法中,定义的基本类型数据的变量都存储在栈中,当这个函数结束以后,其对应的栈就会被回收,此时在该方法体定义的这类变量将不存在了,因此返回时不是直接返回变量的值,而是复制一份,再返回。
本例方法testFinally2中调用return之前,先把result的值1存储在一个指定的位置,然后再去执行finally块中的代码,因此finally块中修改result的值不会影响到方法的返回结果。因此,在代码中的注释的猜想是正确的。

  • 修改的返回值为引用类型
public class TestFinally
{
    public static StringBuffer testFinally3()
    {
        StringBuffer s = new StringBuffer("hello");
        try{
            return s;
        }catch(Exception e){
            return null;
        }finally{
            s.append(" world");
            System.out.println("execute finally3");
        }
    }
    public static void main(String[] args) 
    {
        StringBuffer resultString = testFinally3();
        System.out.println(resultString);   
    }
}

// 结果
D:\JavaProject\demo15>javac TestFinally.java

D:\JavaProject\demo15>java TestFinally
execute finally3
hello world

总结:程序执行到return时首先将返回值存储到一个指定的位置,其次去执行finally块,最后再返回。
对于引用类型的数据,定义该类型的数据变量时数据本身是存储在堆中的,在调用return之前首先把变量s的副本存储到一个指定的位置(s指向的是StringBuffer数据,这里并不是将s指向的数据存储到指定的位置,只是将“指针”s存到了其他地方,但还是指向的是堆内存的StringBuffer数据),由于s为引用类型,因此在finally块中修改s将会修改程序的返回结果。这一点从指针的角度更容易理解。

2. finally 是不是一定会被执行到?

答案:不一定会执行。

1. 当程序在进入try语句之前就出现异常时,会直接结束,不会执行finally块中的语句的代码

public class Test
{
    public static void testFinally()
    {
        int i = 5/0;
        try{
            System.out.println("try block");
        }catch (Exception e){
            System.out.println("catch block");
        }finally{
            System.out.println("finally block");
        }
    }
    public static void main(String[] args)
    {
        testFinally();
    }
}


// result
D:\JavaProject\demo15>javac TestFinally.java

D:\JavaProject\demo15>java TestFinally
Exception in thread "main" java.lang.ArithmeticException: / by zero
        at TestFinally.testFinally(TestFinally.java:5)
        at TestFinally.main(TestFinally.java:16)

2. 当程序在try块中强制退出时也不会去执行finally块中的代码

public class Test
{
    public static void testFinally()
    {
        try{
            System.out.println("try block");
            System.exit(0);
        }catch (Exception e){
            System.out.println("catch block");
        }finally{
            System.out.println("finally block");
        }
    }
    public static void main(String[] args)
    {
        testFinally();
    }
}

// result
D:\JavaProject\demo15>javac TestFinally.java

D:\JavaProject\demo15>java TestFinally
try block

3. try-catch-finally 中,如果 catch 中 return 了,finally 还会执行吗?

答案是:会。 无论何种情况,finally一定会执行 原因和try里面的一样,会先把返回值给复制一下,然后去执行finally,再把catch中的值return出去

4. try-catch-finally 中那个部分可以省略?

catch 和 finally 语句块可以省略其中一个,否则编译会报错。

5. Error 和 Exception 的区别?

Error类和Exception类的父类都是throwable类,他们的区别是:

  1. Error

Error类一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和和预防,遇到这样的错误,建议让程序终止。

  1. Excepton

Exception类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。

Exception类又分为运行时异常(Runtime Exception)和受检查的异常(Checked Exception )

运行时异常:ArithmaticException,IllegalArgumentException,编译能通过,但是一运行就终止了,程序不会处理运行时异常,出现这类异常,程序会终止。
而受检查的异常:要么用try。。。catch捕获,要么用throws字句声明抛出,交给它的父类处理,否则编译不会通过。

常见的异常:
ArrayIndexOutOfBoundsException 数组下标越界异常
ArithmaticException 算数异常 如除数为零
NullPointerException 空指针异常
IllegalArgumentException 不合法参数异常

6. 主线程可以捕获到子线程的异常吗?

前言:
线程设计的理念:“线程的问题应该线程自己本身来解决,而不要委托到外部。”,正常情况下,如果不做特殊的处理,在主线程中是不能够捕获到子线程中的异常的,需要通过一些方式来实现。

原理分析:
首先明确线程代码的边界。其实很简单,Runnable接口的run方法所界定的边界就可以看作是线程代码的边界。

而所有的具体线程都实现这个方法,所以这里就明确了一点,线程代码不能抛出任何checked异常。所有的线程中的checked异常都只能被线程本身消化掉。:) 这样本身也是符合线程的设计理念的,线程本身就是被看作独立的执行片断,它应该对自己负责,所以由它来消化所有的checked异常是很正常的。

但是,线程代码中是可以抛出错误(Error)和运行级别异常(RuntimeException)的。Error可以忽略,因为通常Error是应该留给jvm的,而RuntimeException确是比较正常的,如果在运行过程中满足了某种条件导致线程必须中断,可以选择使用抛出运行级别异常来处理,如下:

public void run() {
if (...) throw new RuntimeException();
}

当线程代码抛出运行级别异常之后,线程会中断。:)这点java中解释得很清楚:

@see Thread All threads that are not daemon threads have died, either by returning from the call to the run method or “by throwing an exception that propagates beyond the run method”.

但是对于invoke此线程的主线程会产生什么影响呢?主线程不受这个影响,不会处理这个RuntimeException,而且根本不能catch到这个异常。会继续执行自己的代码
所以得到结论:线程方法的异常只能自己来处理。

但是,给某个thread设置一个UncaughtExceptionHandler,可以确保在该线程出现异常时能通过回调UncaughtExceptionHandler接口的public void uncaughtException(Thread t, Throwable e) 方法来处理异常,这样的好处或者说目的是可以在线程代码边界之外(Thread的run()方法之外),有一个地方能处理未捕获异常。

但是要特别明确的是:虽然是在回调方法中处理异常,但这个回调方法在执行时依然还在抛出异常的这个线程中!另外还要特别说明一点:如果线程是通过线程池创建,线程异常发生时UncaughtExceptionHandler接口不一定会立即回调。代码示例如下:

package study20170103;
 
/**
 * Created by apple on 17/1/3.
 */
public class ThreadTest extends ThreadGroup{
 
    private ThreadTest(){
        super("ThreadTest");
    }
 
 
    public static void main(String[] args) {

        //传入继承ThreadGroup的类对象
        new Thread(new ThreadTest(),new Runnable() {
            @Override
            public void run() {

                //只能抛出unchecked异常
                throw new NullPointerException();
            }
        }).start();
    }
 
    public   void   uncaughtException(Thread   thread,   Throwable   exception)
    {
        /**
         * 当线程抛出unckecked异常时,系统会自动调用该函数
         ,但是是在抛出异常的线程内执行
         */
        System.out.println(thread.getId());

        //example,   print   stack   trace
        exception.printStackTrace();
    }
}

比之上述方法,还有一种编程上的处理方式可以借鉴,即,有时候主线程的调用方可能只是想知道子线程执行过程中发生过哪些异常,而不一定会处理或是立即处理,那么发起子线程的方法可以把子线程抛出的异常实例收集起来作为一个Exception的List返回给调用方,由调用方来根据异常情况决定如何应对。不过要特别注意的是,此时子线程早以终结。

线程设计的理念:“线程的问题应该线程自己本身来解决,而不要委托到外部。”

3. Java集合

1. Java 中常用的容器有哪些?

容器

2. ArrayList 和 LinkedList 的区别?

ArrayList和LinkedList的区别有以下几点:

  1. ArrayList是实现了基于动态数组的数据结构,而LinkedList是基于链表的数据结构;
  2. 对于随机访问get和set,ArrayList要优于LinkedList,因为LinkedList要移动指针;
  3. 对于添加和删除操作add和remove,一般大家都会说LinkedList要比ArrayList快,因为ArrayList要移动数据。但是实际情况并非这样,对于添加或删除,LinkedList和ArrayList并不能明确说明谁快谁慢,下面会详细分析。

我们结合之前分析的源码,来看看为什么是这样的: ArrayList中的随机访问、添加和删除部分源码如下:

//获取index位置的元素值
public E get(int index) {
    rangeCheck(index); //首先判断index的范围是否合法
 
    return elementData(index);
}
 
//将index位置的值设为element,并返回原来的值
public E set(int index, E element) {
    rangeCheck(index);
 
    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}
 
//将element添加到ArrayList的指定位置
public void add(int index, E element) {
    rangeCheckForAdd(index);
 
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    //将index以及index之后的数据复制到index+1的位置往后,即从index开始向后挪了一位
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index); 
    elementData[index] = element; //然后在index处插入element
    size++;
}
 
//删除ArrayList指定位置的元素
public E remove(int index) {
    rangeCheck(index);
 
    modCount++;
    E oldValue = elementData(index);
 
    int numMoved = size - index - 1;
    if (numMoved > 0)
        //向左挪一位,index位置原来的数据已经被覆盖了
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    //多出来的最后一位删掉
    elementData[--size] = null; // clear to let GC do its work
 
    return oldValue;
}

LinkedList中的随机访问、添加和删除部分源码如下:

//获得第index个节点的值
public E get(int index) {
	checkElementIndex(index);
	return node(index).item;
}
 
//设置第index元素的值
public E set(int index, E element) {
	checkElementIndex(index);
	Node<E> x = node(index);
	E oldVal = x.item;
	x.item = element;
	return oldVal;
}
 
//在index个节点之前添加新的节点
public void add(int index, E element) {
	checkPositionIndex(index);
 
	if (index == size)
		linkLast(element);
	else
		linkBefore(element, node(index));
}
 
//删除第index个节点
public E remove(int index) {
	checkElementIndex(index);
	return unlink(node(index));
}
 
//定位index处的节点
Node<E> node(int index) {
	// assert isElementIndex(index);
	//index<size/2时,从头开始找
	if (index < (size >> 1)) {
		Node<E> x = first;
		for (int i = 0; i < index; i++)
			x = x.next;
		return x;
	} else { //index>=size/2时,从尾开始找
		Node<E> x = last;
		for (int i = size - 1; i > index; i--)
			x = x.prev;
		return x;
	}
}

从源码可以看出,ArrayList想要get(int index)元素时,直接返回index位置上的元素,而LinkedList需要通过for循环进行查找,虽然LinkedList已经在查找方法上做了优化,比如index < size / 2,则从左边开始查找,反之从右边开始查找,但是还是比ArrayList要慢。这点是毋庸置疑的。
ArrayList想要在指定位置插入或删除元素时,主要耗时的是System.arraycopy动作,会移动index后面所有的元素;LinkedList主耗时的是要先通过for循环找到index,然后直接插入或删除。这就导致了两者并非一定谁快谁慢。

所以当插入的数据量很小时,两者区别不太大,当插入的数据量大时,大约在容量的1/10之前,LinkedList会优于ArrayList,在其后就劣与ArrayList,且越靠近后面越差。所以个人觉得,一般首选用ArrayList,由于LinkedList可以实现栈、队列以及双端队列等数据结构,所以当特定需要时候,使用LinkedList,当然咯,数据量小的时候,两者差不多,视具体情况去选择使用;当数据量大的时候,如果只需要在靠前的部分插入或删除数据,那也可以选用LinkedList,反之选择ArrayList反而效率更高。

ArrayList 实现 RandomAccess 接口有何作用?为何 LinkedList 却没实现这个接口?

RandomAccess 接口只是一个标志接口,只要 List 集合实现这个接口,就能支持快速随机访问。通过查看 Collections 类中的 binarySearch() 方法,可以看出,判断 List 是否实现 RandomAccess 接口来实行indexedBinarySerach(list, key) 或 iteratorBinarySerach(list, key)方法。再通过查看这两个方法的源码发现:实现 RandomAccess 接口的 List 集合采用一般的 for 循环遍历,而未实现这接口则采用迭代器,即 ArrayList 一般采用 for 循环遍历,而 LinkedList 一般采用迭代器遍历;

ArrayList 用 for 循环遍历比 iterator 迭代器遍历快,LinkedList 用 iterator 迭代器遍历比 for 循环遍历快。所以说,当我们在做项目时,应该考虑到 List 集合的不同子类采用不同的遍历方式,能够提高性能。

4. ArrayList 的扩容机制?

ArrayList扩容

5. Array 和 ArrayList 有何区别?什么时候更适合用 Array?

存储内容比较:
Array数组可以包含基本类型和对象类型,
ArrayList却只能包含对象类型。
但是需要注意的是:Array数组在存放的时候一定是同种类型的元素。ArrayList就不一定了,因为ArrayList可以存储Object。

使用场景:
当集合长度固定时,使用数组;当集合的长度不固定时,使用ArrayList。

6. HashMap 的实现原理/底层数据结构?JDK1.7 和 JDK1.8

HashMap详解

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

先看源码如何计算Hash值,

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算机的运行效率:加法(减法)>乘法>除法>取模

数组一旦达到容量的阈值的时候就需要对数组进行扩容。那么扩容就意味着要进行数组的移动,数组一旦移动,每移动一次就要重回记算索引,这个过程中牵扯大量元素的迁移,就会大大影响效率。那么如果说我们直接使用与运算,这个效率是远远高于取模运算的。

// 看看putVal 方法使用与运算
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
    
tab[i = (n - 1) & hash]中tab就是HashMap的实体数组其下标通过i = (n - 1) & hash来获取n表示数组长度hash表示hashCode值),
1与运算(n-1) & hash
2取代取模运算hash%length
两种方式记算出来的结果是一致的n就是length),也就是(length-1)&hash = hash%length

当数组的长=长度为2的指数次幂时例如假设数组长度为4哈希值为10
(n-1) & hash = (4-1) & 10 = 00000011 & 00001010 = 00000010 = 2
hash % length = 10 % 4 = 2
即length-1)&hash = hash&length

但是当数组的长=长度不为2的指数次幂时例如再假设数组长度为5哈希值10
(n-1) & hash = (5-1) & 10 = 00000100 & 00001010 = 00000000 = 0 
hash % length = 10 % 5 = 2
即length-1)&hashhash&length

通过上面看出这必须保证数组长度为2的整数次幂

综上所述使用位运算来加快计算的效率,只有当数组长度为2的指数次幂时,其计算得出的值才能和取模算法的值相等,并且保证能取到数组的每一位,减少哈希碰撞,不浪费大量的数组资源。

8. HashMap 多线程死循环问题?

https://blog.csdn.net/m0_46405589/article/details/109206432

9. 说说Hashtable、HashMap、TreeMap的区别?

实现方面

HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary类。它们都同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口。存储的内容是基于key-value的键值对映射,不能有重复的key,而且一个key只能映射一个value。HashSet底层就是基于HashMap实现的。

为空方面

Hashtable的key、value都不能为null;HashMap的key、value可以为null,不过只能有一个key为null,但可以有多个null的value;TreeMap键、值都不能为null。

排序方面

Hashtable、HashMap具有无序特性。TreeMap是利用红黑树实现的(树中的每个节点的值都会大于或等于它的左子树中的所有节点的值,并且小于或等于它的右子树中的所有节点的值),实现了SortMap接口,能够对保存的记录根据键进行排序。所以一般需求排序的情况下首选TreeMap,默认按键的升序排序(深度优先搜索),也可以自定义实现Comparator接口实现排序方式。

反思&扩展

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。 Hashtable现在很少出现了,大家更多的会使用ConcurrentHashMap,引导面试官提问ConcurrentHashMap。

10. 说说ConcurrentHashMap原理与实现?

原理与实现主要是锁的原理与实现!我们可以从JDK1.7开始聊起:

JDK1.7版本

ConcurrentHashMap内部使用段(Segment), ConcurrentLevel 有16个分段,这16个分段有独立的锁机制,每个独立的机制都是一张表,表的下面是链表,这样就可以支持并发的同时保证每张表的线程安全,大大的题高了效率。

JDK1.8版本

ConcurrentHashMap内部使用sychronized + volatile + CAS 的实现降低锁的粒度,大家可以认为粒度就是HashEntry(首节点)。 让我们看看具体是如何实现的:

  • 插入、删除、扩容的时候都对数组中相应位置的元素加锁了,加锁用的是synchronized
  • table数组、Node中的val和next、以及一些控制字段都加了volatile
  • 在更新一些关键变量的时候用到了sun.misc.Unsafe中的一些方法

concurrentHashMap

ConcurrentHashMap有什么缺陷吗?

ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。

ConcurrentHashMap在JDK 7和8之间的区别

JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点) JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了 JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档

11. HashSet 的实现原理?

HashSet是最常用的Set集合之一,可以保证元素的唯一性。

底层原理

HashSet底层完全就是在HashMap的基础上包了一层,只不过存储的时候value是默认存储了一个Object的静态常量,取的时候也是只返回key,所以看起来就像List一样。

构造方法

可以看到四个构造方法都是初始化一个HashMap,初始化的容量和装填因子也是直接用的HashMap的默认配置。

 private transient HashMap<E,Object> map;

 public HashSet() {
     map = new HashMap<>();
 }

 public HashSet(int initialCapacity) {
     map = new HashMap<>(initialCapacity);
 }

 public HashSet(int initialCapacity, float loadFactor) {
     map = new HashMap<>(initialCapacity, loadFactor);
 }

 public HashSet(Collection<? extends E> c) {
     map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
     addAll(c);
 }

add()/remove()/contains()方法

可以看到这三个方法都是直接调用的HashMap的实现。

 public boolean add(E e) {
     return map.put(e, PRESENT)==null;
 }

 public boolean remove(Object o) {
     return map.remove(o)==PRESENT;
 }

 public boolean contains(Object o) {
     return map.containsKey(o);
 }

保证唯一性

HashSet是调用的HashMap的put()方法,而put()方法中有这么一行逻辑,如果哈希值和key都一样,就会直接拿新值覆盖旧值,而HashSet就是利用这个特性来保证唯一性。

 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
     e = p;

所以在存放对象的时候需要重写hashCode()和equals()方法,因为就是用这两个方法来判断唯一性的,否则就会出现下面这样的情况,创建两个属性一样的对象,放入HashSet中会发现重复了,那是因为创建两个对象肯定哈希值是不一样的,所以需要自己重写hashCode()和equals()。

public class TestVo {

   int id;
   String name;

   public TestVo(int id, String name) {
       this.id = id;
       this.name = name;
   }

   @Override
   public String toString() {
       return  "TestVo{" +
               "id=" + id +
               ", name='" + name + '\'' +
               '}';
   }
}

public static void main(String[] args){
   HashSet set = new HashSet();

   TestVo testVo1 = new TestVo(1,"a");
   TestVo testVo2 = new TestVo(1,"a");

   set.add(testVo1);
   set.add(testVo2);

   Iterator<TestVo> iterator = set.iterator();
   while (iterator.hasNext()){
       System.out.println(iterator.next());
   }
}

// 输出结果
TestVo{id=1, name='a'}
TestVo{id=1, name='a'}

重写hashCode()和equals()方法,都基于id来判断,这样重复``id的就不会重复存入了,所以存入对象的时候是可以自己设置按什么规则来去重的。

@Override
   public int hashCode() {
       return id;
   }

   @Override
   public boolean equals(Object obj) {
       return obj.equals(id);
   }
}
// 输出结果
TestVo{id=1, name='a'}

4. Java并发

1. 并行和并发有什么区别?

并发性(concurrency)和并行性(parallel)是两个概念;

并行指在同一时刻,有多条指令(线程)在多个处理器上同时执行;
并发指在同一时刻只能有一个指令(线程)执行,但多个进程指令被快速轮换执行,使得宏观上具有多个进程同时执行的效果。 并行是真正意义上,同一时刻做多件事情,而并发在同一时刻只会做一件事件,只是可以将时间切碎,交替做多件事情。

网上有个例子挺形象的:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

2. 线程和进程的区别?

进程

进程是处于运行中的程序,具有一定的独立能力,进程是系统进行资源分配和调度的一个独立单位。 进程特征:

A、独立性:进程是系统中独立存在的实体,可以拥有自己独立的资源,每个进程都拥有自己的私有地址地址。在没有经过进程本身允许的情况下,一个用户进程不可以访问其他进程地址空间。
B、动态性:进程和程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。
在程序中加入了时间概念,进程具有自己的生命周期和各种不同的状态,这些概念是程序不具备的。
C、并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。

多线程

多线程则扩展了多进程的概念,使得同一个进程可以同时并发处理多个任务。线程也被称为轻量级进程(Lightweight Process),线程是进程的执行单元。 就像进程在操作系统中的地位一样,线程在程序中是独立、并发执行流。当进程被初始化后,主线程就被创建。对于绝大多数应用程序来说,通常仅要一个主线程, 但我们也可以在该进程内创建多条顺序执行流,这些顺序执行流就是线程,每条线程也互相独立的。

多线程和进程区别

  • 线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆、栈、程序计数器、局部变量,但不能拥有系统资源,
  • 线程与父进程的其他线程共享该进程所有的全部资源。因为多个线程共享父进程的全部资源。- 进程通常是隔离的,进程不共享公共内存,而线程则共享。
  • 线程可以完成一定的任务,可与其他线程共享父进程中的变量和部分环境,相互之间协作共同完成进程所要完成的任务。
  • 线程是独立运行的,它并不知道进程中是否还有其他进程存在。线程的执行是抢占方式的,也就是说,当前运行的线程在任何时候都可以被挂起,以便其他线程运行。
  • 一个线程可以创建和撤销另一个线程,同一个进程中的多个线程可以并发执行。
  • 一个程序运行后至少有一个进程,一个进程可以包含多个线程。至少包含一个线程。

3. 守护线程是什么?

守护线程是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。守护线程不阻止 Java 虚拟机 ( JVM ) 退出的线程。当所有非守护线程终止时,JVM 会放弃所有剩余的守护线程。
守护线程通常用于为其他线程执行一些支持或服务任务,但我们应该考虑到它们可能随时被放弃。
要将一个线程作为守护线程启动,应该在调用 start() 之前使用 setDaemon()方法设置为守护线程。如下所示

Thread daemon = new Thread(() -> System.out.println("Hello from daemon!")); 
daemon.setDaemon(true); daemon.start();
  • setDaemon(true) 必须在 start() 之前设置,否则会抛出IllegalThreadStateException异常,该线程仍默认为用户线程,继续执行
  • 守护线程创建的线程也是守护线程
  • 守护线程不应该访问、写入持久化资源,如文件、数据库,因为它会在任何时间被停止,导致资源未释放、数据写入中断等问题
  • 后台进程在不执行finally子句的情况下就会终止其run()方法。
  • JVM 中的垃圾回收线程就是典型的守护线程

4. 创建线程的几种方式?

1. 继承Thread类

并复写run方法,创建该类对象,调用start方法开启线程。此方式没有返回值。

2. 实现Runnable接口

复写run方法,创建Thread类对象,将Runnable子类对象传递给Thread类对象。调用start方法开启线程。此方法2较之方法1好,将线程对象和线程任务对象分离开。降低了耦合性,利于维护。此方式没有返回值。

3. 创建FutureTask对象

创建Callable子类对象,复写call(相当于run)方法,将其传递给FutureTask对象(相当于一个Runnable)。 创建Thread类对象,将FutureTask对象传递给Thread对象。调用start方法开启线程。这种方式可以获得线程执行完之后的返回值。该方法使用Runnable功能更加强大的一个子类.这个子类是具有返回值类型的任务方法。

4. 线程池

提供了一个线程队列,队列中保存着所有等待状态的线程。避免了创建与销毁额外开销,提高了响应的速度。

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

public class NewThreadDemo {

    public static void main(String[] args) throws Exception {
        
        //第一种方式
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println("第1种方式:new Thread 1");
            }
        };
        t1.start();
        
        TimeUnit.SECONDS.sleep(1);
        
        //第二种方式
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("第2种方式:new Thread 2");
            }
        });
        t2.start();

        TimeUnit.SECONDS.sleep(1);
        
        
        //第三种方式
        FutureTask<String> ft = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                String result = "第3种方式:new Thread 3";
                return result;
            }
        });
        Thread t3 = new Thread(ft);
        t3.start();
        
        // 线程执行完,才会执行get(),所以FutureTask也可以用于闭锁
        String result = ft.get();
        System.out.println(result);
        
        TimeUnit.SECONDS.sleep(1);
        
         //第四种方式
        ExecutorService pool = Executors.newFixedThreadPool(5);

        Future<String> future = pool.submit(new Callable<String>(){
            @Override
            public String call() throws Exception {
                String result = "第4种方式:new Thread 4";
                return result;
            }
        });

        pool.shutdown();
        System.out.println(future.get());
    }
}

5. Runnable 和 Callable 有什么区别?

Runnable 接口表示必须在单独的线程中运行的计算单位,它只有一个 run() 方法。Runnable 接口不允许此方法返回值或抛出未经检查的异常。

Callable 接口表示具有返回值的任务,它只有一个 call() 方法。call() 方法可以返回一个值 ( 可以是 Void ),也可以抛出一个异常。Callable 通常在 ExecutorService 实例中用于启动异步任务,然后调用返回的 Future 实例以获取其值。

6. 线程状态及转换?

线程的状态一般包含以下五种:初始状态、可运行状态、运行状态、休眠状态、终止状态
线程状态

通用的线程周期

  1. 初始状态:
    指的是线程已经被创建但还不允许分配CPU资源。这个状态是编程语言特有的,而且这里的“创建”也是指编程语言层面的被创建,实际上并没有在操作系统层面上创建线程。
  2. 可运行状态:
    在这个状态下线程可以分配CPU资源,而且真正的操作系统级别的线程已经被创建,线程一旦分配到CPU资源就会立即运行。
  3. 运行状态:
    当有空闲CPU资源时,操作系统会挑选一个处于可运行状态的线程并为其分配CPU资源,被挑选出来分配到CPU资源的线程就会进入运行状态。
  4. 休眠状态:
    当运行状态的线程调用一个阻塞API或者等待某个事件时,那么它的状态就会从运行状态转换为休眠状态。处于休眠状态的线程永远不会分配到CPU资源。当等待的事件出现时,休眠状态的线程就会转换到可运行状态。
  5. 终止状态:
    线程运行完成或者出现异常时就会进入终止状态,终止状态的线程不会再转变成其他状态。线程进入终止状态意味着生命周期的结束。

Java 中线程的生命周期

Java 中的线程一共有6个状态:

  1. NEW(新建)
  2. RUNNABLE(可运行/运行)
  3. BLOCKED(阻塞)
  4. WAITING(无限时等待)
  5. TIMED_WAITING(有限时等待)
  6. TERMINATED(终止)

1.NEW → RUNNABLE
NEW 状态在 Java 语言中对应于调用start()方法之前的Thread的实例。所以从 NEW 到 RUNNABLE 的转换很简单就是调用一下start()方法。

2.RUNNABLE ⇌ BLOCKED
只有一种情况会让线程从RUNNABLE 状态转换为 BLOCKED 状态,就是线程等待 synchronized 隐式锁。synchronized 关键字修饰的代码块、方法在同一时刻只允许一个线程执行,其他线程只能等待,等待的线程就会从 RUNNABLE 状态转换成 BLOCKED 状态。而当线程获取到隐式锁时就会从 BLOCKED 状态转换成 RUNNABLE 状态。
应当注意的是:当线程调用阻塞API时,在操作系统层面上看,线程会进入休眠态,但是在Java层面上看,此时这个Java线程依然是RUNNABLE 状态。

3.RUNNABLE ⇌ WAITING
有三种情况会触发 RUNNABLE 和 WAITING 之间的转换:

  • 场景一:在 synchronized 代码块中调用 object.wait() 方法
  • 场景二:处于 RUNNABLE 状态的线程调用thread.join()方法等待某个线程运行完成。例如thread1中有一行代码是thread2.join()则执行这行代码后thread1会从RUNNABLE 状态转换成 WAITING 状态,直到thread2执行完成以后,thread1才会从 WAITING 状态再次回到 RUNNABLE 状态。
  • 场景三:调用LockSupport.park()方法,会让当前线程从RUNNABLE 转换为 WAITING。当某个线程调用了LockSupport.unpark(thread)时,thread方法就会从WAITING状态转换成RUNNABLE状态。

4.RUNNABLE ⇌ TIMED_WAITING
有四种场景可以使得线程从 RUNNABLE 状态转换到 TIMED_WAITING 状态:

  • 场景一:Object.wait(long timeout)
  • 场景二:Thread.join(long timeout)
  • 场景三:LockSupport.parkUntil(long deadline) (还有一个park型方法,这里不列举了)
  • 场景四:Thread.sleep(long timeout)

可以看出,这四种场景的前三种都是上面提到的函数的带时间参数的形式,最后一个是我们最直接可以想到的sleep。

5.RUNNABLE → TERMINATED
线程执行完 run() 方法后就会自动进入 TERMINATED 状态,如果抛出异常的话也会进入到这个状态。有时候,我们需要强行停止 run() 方法的运行,这时候我们只需要调用线程的 interrupt() 方法即可让线程直接进入 TERMINATED 状态。 应当注意的是,Java 还提供了Thread.stop()方法强制停止线程,但这个方法非常残暴,它使得线程直接停止而无法执行后续必要操作,比如无法释放已经获取的锁。而interrupt就比较温和,它只是通知一下线程应当停止了,而线程什么时候停止则取决于线程本身。如果线程处于WAITING、TIMED_WAITING时,那么interrupt则会唤醒线程,让其重新进入 RUNNABLE 状态然后抛出 InterruptedException 异常。如果线程处于 RUNNABLE 状态,则线程会在合适的时候检测一下自己是否被需要中断,如果需要中断则首先需要做一些必要的操作,然后再进入 TERMINATED 状态。

7. sleep() 和 wait() 的区别?

sleep()方法(休眠)是线程类(Thread)的静态方法,调用此方法会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,因此休眠时间结束后会自动恢复(线程回到就绪状态)。
wait()是 Object 类的方法,调用对象的 wait()方法导致当前线程放弃对象的锁(线程暂停执行),进入对象的等待池(wait pool),只有调用对象的 notify()方法(或 notifyAll()方法)时才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。

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

start() :

它的作用是启动一个新线程。 通过start()方法来启动的新线程,处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行相应线程的run()方法,这里方法run()称为线程体,它包含了要执行的这个线程的内容,run方法运行结束,此线程随即终止。start()不能被重复调用。用start方法来启动线程,真正实现了多线程运行,即无需等待某个线程的run方法体代码执行完毕就直接继续执行下面的代码。这里无需等待run方法执行完毕,即可继续执行下面的代码,即进行了线程切换。

run() :

run()就和普通的成员方法一样,可以被重复调用。 如果直接调用run方法,并不会启动新线程!程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的。 总结:调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行。

public class Test {
    static void pong(){
        System.out.print("pong");
    }
    public static void main(String[] args) {
        Thread t=new Thread(){
            public void run(){
                pong();
            }
        };
        t.run();
        System.out.print("ping");       
    }
}
// 运行结果:
// pongping

总结一下:

  • start() 可以启动一个新线程,run()不能
  • start()不能被重复调用,run()可以
  • start()中的run代码可以不执行完就继续执行下面的代码,即进行了线程切换。直接调用- run方法必须等待其代码全部执行完才能继续执行下面的代码。
  • start() 实现了多线程,run()没有实现多线程。

9. 在 Java 程序中怎么保证多线程的运行安全?

保证安全的方法

  1. 使用手动锁lock
 Lock lock = new ReentrantLock();
 lock.lock();
 try {
 System.out.println("获得锁");
    }
  catch(Exception e){
//TODO handler exception
      }finally {
  System.out.println("释放锁");
  lock.unlock();
}
  1. 使用线程安全的类
    如使用java.util.concurrent下的类,Vector.HashTable、StringBuffer。

  2. 使用自动锁synchronized关键字
    可以用于代码块,方法(静态方法,同步锁是当前字节码对象;实例方法,同步锁是实例对象)

  3. 使用volatile关键字
    防止指令重排,被volatile修饰的变量的值,将不会被本地线程缓存,所有对该变量的读写都是直接操作共享内存,从而确保多个线程能正确的处理该变量。

扩展

  1. 线程安全性问题体现在

    • 原子性:一个或者多个操作在CPU执行的过程中不被中断的特性。线程切换带来的原子性问题。
    • 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。
    • 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。
  2. 解决办法

    • 原子性问题:JDK Atomic开头的原子类、synchronized、LOCK
    • 可见性问题: synchronized、volatile、LOCK
    • 有序性问题: Happens-Before规则

Happen-Before规则

  • 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作先行发生于书写在后面的操作
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则: Thread对象的start()方法先行发生此线程的每一个动作
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

10. Java 线程同步的几种方法?

1. 线程同步方法

用synchronized关键字修饰方法。 由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

public class Bank {
    private int count = 0;// 账户余额

    // 存钱
    public synchronized void addMoney(int money) {
        count += money;
        System.out.println(System.currentTimeMillis() + "存进:" + money);
    }

    // 取钱
    public synchronized void subMoney(int money) {
        if (count - money < 0) {
            System.out.println("余额不足");
            return;
        }
        count -= money;
        System.out.println(+System.currentTimeMillis() + "取出:" + money);
    }

    // 查询
    public void lookMoney() {
        System.out.println("账户余额:" + count);
    }
}

2. 同步代码块

用synchronized关键字修饰语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步

public class Bank {
    private int count = 0;// 账户余额

    // 存钱
    public  void addMoney(int money) {
        synchronized(this){
            count += money;
        }
        
        System.out.println(System.currentTimeMillis() + "存进:" + money);
    }

    // 取钱
    public  void subMoney(int money) {
        synchronized(this){
            if (count - money < 0) {
                System.out.println("余额不足");
                return;
            }
            count -= money;
        }
        
        System.out.println(+System.currentTimeMillis() + "取出:" + money);
    }

    // 查询
    public void lookMoney() {
        System.out.println("账户余额:" + count);
    }
}

注:同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。

  1. Volatile

a.volatile关键字为域变量的访问提供了一种免锁机制
b.使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新
c.因此每次使用该域就要重新计算,而不是使用寄存器中的值
d.volatile不会提供任何原子操作,它也不能用来修饰final类型的变量

public class Bank {
    private volatile int count = 0;// 账户余额

    // 存钱
    public  void addMoney(int money) {
        count += money;
        System.out.println(System.currentTimeMillis() + "存进:" + money);
    }

    // 取钱
    public  void subMoney(int money) {
        if (count - money < 0) {
            System.out.println("余额不足");
            return;
        }
        count -= money;
        System.out.println(System.currentTimeMillis() + "取出:" + money);
    }

    // 查询
    public void lookMoney() {
        System.out.println("账户余额:" + count);
    }
}

因为volatile不能保证原子操作导致的,因此volatile不能代替 synchronized。此外volatile会组织编译器对代码优化。它的原理是每次要线程要访问volatile修饰 的变量时都是从内存中读取,而不是存缓存当中读取,因此每个线程访问到的变量值都是一样的。这样就保证了同步。

4. 使用重入锁实现线程同步

在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁, 它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。
ReenreantLock类的常用方法有:
ReentrantLock() : 创建一个ReentrantLock实例
lock() : 获得锁
unlock() : 释放锁
注:ReentrantLock()还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用

public class Bank {
    private int count = 0;// 账户余额

    // 需要声明这个锁
    private Lock lock = new ReentrantLock();

    // 存钱
    public void addMoney(int money) {
        lock.lock();
        try {
            count += money;
            System.out.println(System.currentTimeMillis() + "存进:" + money);
        } finally {
            lock.unlock();
        }
    }

    // 取钱
    public void subMoney(int money) {
        lock.lock();
        try {

            if (count - money < 0) {
                System.out.println("余额不足");
                return;
            }
            count -= money;
            System.out.println(+System.currentTimeMillis() + "取出:" + money);
        } finally {
            lock.unlock();
        }
    }

    // 查询
    public void lookMoney() {
        System.out.println("账户余额:" + count);
    }
}

1、ReentrantLock()还可以通过public ReentrantLock(boolean fair)构造方法创建公平锁,即,优先运行等待时间最长的线程,这样大幅度降低程序运行效率。
2、关于Lock对象和synchronized关键字的选择:
(1)、最好两个都不用,使用一种java.util.concurrent包提供的机制,能够帮助用户处理所有与锁相关的代码。
(2)、如果synchronized关键字能够满足用户的需求,就用synchronized,他能简化代码。
(3)、如果需要使用更高级的功能,就用ReentrantLock类,此时要注意及时释放锁,否则会出现死锁,通常在finally中释放锁。

5. ThreadLocal

ThreadLocal 类的常用方法
ThreadLocal() : 创建一个线程本地变量
get() : 返回此线程局部变量的当前线程副本中的值
initialValue() : 返回此线程局部变量的当前线程的"初始值"
set(T value) : 将此线程局部变量的当前线程副本中的值设置为value

public class Bank {
     private static ThreadLocal<Integer> count = new ThreadLocal<Integer>(){  
         @Override  
            protected Integer initialValue() {  
                // TODO Auto-generated method stub  
                return 0;  
            }  
     };


    // 存钱
    public void addMoney(int money) {
        count.set(count.get()+money);  
        System.out.println(System.currentTimeMillis() + "存进:" + money);  
    }

    // 取钱
    public void subMoney(int money) {
        if (count.get() - money < 0) {  
            System.out.println("余额不足");  
            return;  
        }  
        count.set(count.get()- money);  
        System.out.println(+System.currentTimeMillis() + "取出:" + money);  
    }

    // 查询
    public void lookMoney() {
        System.out.println("账户余额:" + count.get());
    }
}

ThreadLocal的原理:

如果使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变 量副本,而不会对其他线程产生影响。 即每个线程运行的都是一个副本,也就是说存钱和取钱是两个账户,只是名字相同而已,两个线程间的count没有关系。所以就会发生上面的效果。

ThreadLocal与同步机制
a.ThreadLocal与同步机制都是为了解决多线程中相同变量的访问冲突问题
b.前者采用以”空间换时间”的方法,后者采用以”时间换空间”的方式

ThreadLocal并不能替代同步机制,两者面向的问题领域不同。
1:同步机制是为了同步多个线程对相同资源的并发访问,是为了多个线程之间进行通信的有效方式;
2:而threadLocal是隔离多个线程的数据共享,从根本上就不在多个线程之间共享变量,这样当然不需要对多个线程进行同步了。

11. Thread.interrupt() 方法的工作原理是什么?

在 Java 中,线程的中断 interrupt 只是改变了线程的中断状态,至于这个中断状态改变后带来的结果,那是无法确定的,有时它更是让停止中的线程继续执行的唯一手段。不但不是让线程停止运行,反而是继续执行线程的手段。

在一个线程对象上调用 interrupt() 方法,真正有影响的是 wait、join、sleep 方法,当然这 3 个方法包括它们的重载方法。请注意:上面这三个方法都会抛出 InterruptedException。

1、对于 wait 中的等待 notify、notifyAll 唤醒的线程,其实这个线程已经“暂停”执行,因为它正在某一对象的休息室中,这时如果它的中断状态被改变,那么它就会抛出异常。这个 InterruptedException 异常不是线程抛出的,而是 wait 方法,也就是对象的 wait 方法内部会不断检查在此对象上休息的线程的状态,如果发现哪个线程的状态被置为已中断,则会抛出 InterruptedException,意思就是这个线程不能再等待了,其意义就等同于唤醒它了,然后执行 catch 中的代码。

2、 对于 sleep 中的线程,如果你调用了 Thread.sleep(一年);现在你后悔了,想让它早些醒过来,调用 interrupt() 方法就是唯一手段,只有改变它的中断状态,让它从 sleep 中将控制权转到处理异常的 catch 语句中,然后再由 catch 中的处理转换到正常的逻辑。同样,对于 join 中的线程你也可以这样处理。

12. 谈谈对 ThreadLocal 的理解?

在处理多线程并发安全的方法中,最常用的方法,就是使用锁,通过锁来控制多个不同线程对临界区的访问。
但是,无论是什么样的锁,乐观锁或者悲观锁,都会在并发冲突的时候对性能产生一定的影响。 那有没有一种方法,可以彻底避免竞争呢?
答案是肯定的,这就是ThreadLocal。

从字面意思上看,ThreadLocal可以解释成线程的局部变量,也就是说一个ThreadLocal的变量只有当前自身线程可以访问,别的线程都访问不了,那么自然就避免了线程竞争。
因此,ThreadLocal提供了一种与众不同的线程安全方式,它不是在发生线程冲突时想办法解决冲突,而是彻底的避免了冲突的发生。

基本使用

创建一个ThreadLocal对象:

private ThreadLocal<Integer> localInt = new ThreadLocal<>();

上述代码创建一个localInt变量,由于ThreadLocal是一个泛型类,这里指定了localInt的类型为整数。

下面展示了如果设置和获取这个变量的值:

public int setAndGet(){
    localInt.set(8);
    return localInt.get();
}

上述代码设置变量的值为8,接着取得这个值。
由于ThreadLocal里设置的值,只有当前线程自己看得见,这意味着你不可能通过其他线程为它初始化值。为了弥补这一点,ThreadLocal提供了一个withInitial()方法统一初始化所有线程的ThreadLocal的值:

private ThreadLocal<Integer> localInt = ThreadLocal.withInitial(() -> 6);

上述代码将ThreadLocal的初始值设置为6,这对全体线程都是可见的。

原理

ThreadLocal变量只在单个线程内可见,那它是如何做到的呢?我们先从最基本的get()方法说起:

public T get() {
    //获得当前线程
    Thread t = Thread.currentThread();
    //每个线程 都有一个自己的ThreadLocalMap,
    //ThreadLocalMap里就保存着所有的ThreadLocal变量
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        //ThreadLocalMap的key就是当前ThreadLocal对象实例,
        //多个ThreadLocal变量都是放在这个map中的
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            //从map里取出来的值就是我们需要的这个ThreadLocal变量
            T result = (T)e.value;
            return result;
        }
    }
    // 如果map没有初始化,那么在这里初始化一下
    return setInitialValue();
}

可以看到,所谓的ThreadLocal变量就是保存在每个线程的map中的。这个map就是Thread对象中的threadLocals字段。如下:

ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocal.ThreadLocalMap是一个比较特殊的Map,它的每个Entry的key都是一个弱引用:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    //key就是一个弱引用
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

这样设计的好处是,如果这个变量不再被其他对象使用时,可以自动回收这个ThreadLocal对象,避免可能的内存泄露(注意,Entry中的value,依然是强引用,如何回收,见下文分解)。

理解ThreadLocal中的内存泄漏问题

虽然ThreadLocalMap中的key是弱引用,当不存在外部强引用的时候,就会自动被回收,但是Entry中的value依然是强引用。这个value的引用链条如下:
关系
可以看到,只有当Thread被回收时,这个value才有被回收的机会,否则,只要线程不退出,value总是会存在一个强引用。但是,要求每个Thread都会退出,是一个极其苛刻的要求,对于线程池来说,大部分线程会一直存在在系统的整个生命周期内,那样的话,就会造成value对象出现泄漏的可能。处理的方法是,在ThreadLocalMap进行set(),get(),remove()的时候,都会进行清理:
以getEntry()为例:

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        //如果找到key,直接返回
        return e;
    else
        //如果找不到,就会尝试清理,如果你总是访问存在的key,那么这个清理永远不会进来
        return getEntryAfterMiss(key, i, e);
}

// 下面是getEntryAfterMiss()的实现:

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        // 整个e是entry ,也就是一个弱引用
        ThreadLocal<?> k = e.get();
        //如果找到了,就返回
        if (k == key)
            return e;
        if (k == null)
            //如果key为null,说明弱引用已经被回收了
            //那么就要在这里回收里面的value了
            expungeStaleEntry(i);
        else
            //如果key不是要找的那个,那说明有hash冲突,这里是处理冲突,找下一个entry
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

真正用来回收value的是expungeStaleEntry()方法,在remove()和set()方法中,都会直接或者间接调用到这个方法进行value的清理:

从这里可以看到,ThreadLocal为了避免内存泄露,也算是花了一番大心思。不仅使用了弱引用维护key,还会在每个操作上检查key是否被回收,进而再回收value。

但是从中也可以看到,ThreadLocal并不能100%保证不发生内存泄漏。

比如,很不幸的,你的get()方法总是访问固定几个一直存在的ThreadLocal,那么清理动作就不会执行,如果你没有机会调用set()和remove(),那么这个内存泄漏依然会发生。

因此,一个良好的习惯依然是:当你不需要这个ThreadLocal变量时,主动调用remove(),这样对整个系统是有好处的。

ThreadLocalMap中的Hash冲突处理

ThreadLocalMap作为一个HashMap和java.util.HashMap的实现是不同的。对于java.util.HashMap使用的是链表法来处理冲突.
但是,对于ThreadLocalMap,它使用的是简单的线性探测法,如果发生了元素冲突,那么就使用下一个槽位存放:
关系

/**
* Set the value associated with key.
*
* @param key the thread local object
* @param value the value to be set
*/
private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
            e != null;
            e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

可以被继承的ThreadLocal——InheritableThreadLocal

在实际开发过程中,我们可能会遇到这么一种场景。主线程开了一个子线程,但是我们希望在子线程中可以访问主线程中的ThreadLocal对象,也就是说有些数据需要进行父子线程间的传递。比如像这样:

public static void main(String[] args) {
    ThreadLocal threadLocal = new ThreadLocal();
    IntStream.range(0,10).forEach(i -> {
        //每个线程的序列号,希望在子线程中能够拿到
        threadLocal.set(i);
        //这里来了一个子线程,我们希望可以访问上面的threadLocal
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + ":" + threadLocal.get());
        }).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
}

// 执行上述代码,你会看到: 
Thread-0:null
Thread-1:null
Thread-2:null
Thread-3:null

因为在子线程中,是没有threadLocal的。如果我们希望子线可以看到父线程的ThreadLocal,那么就可以使用InheritableThreadLocal。顾名思义,这就是一个支持线程间父子继承的ThreadLocal,将上述代码中的threadLocal使用InheritableThreadLocal:

InheritableThreadLocal threadLocal = new InheritableThreadLocal();

// 再执行,就能看到:
Thread-0:0
Thread-1:1
Thread-2:2
Thread-3:3
Thread-4:4

可以看到,每个线程都可以访问到从父进程传递过来的一个数据。虽然InheritableThreadLocal看起来挺方便的,但是依然要注意以下几点:

变量的传递是发生在线程创建的时候,如果不是新建线程,而是用了线程池里的线程,就不灵了 变量的赋值就是从主线程的map复制到子线程,它们的value是同一个对象,如果这个对象本身不是线程安全的,那么就会有线程安全问题

13. 在哪些场景下会使用到 ThreadLocal?

场景一:代替参数的显式传递

当我们在写API接口的时候,通常Controller层会接受来自前端的入参,当这个接口功能比较复杂的时候,可能我们调用的Service层内部还调用了 很多其他的很多方法,通常情况下,我们会在每个调用的方法上加上需要传递的参数。

但是如果我们将参数存入ThreadLocal中,那么就不用显式的传递参数了,而是只需要ThreadLocal中获取即可。

这个场景其实使用的比较少,一方面显式传参比较容易理解,另一方面我们可以将多个参数封装为对象去传递。

场景二:全局存储用户信息

在现在的系统设计中,前后端分离已基本成为常态,分离之后如何获取用户信息就成了一件麻烦事,通常在用户登录后, 用户信息会保存在Session或者Token中。这个时候,我们如果使用常规的手段去获取用户信息会很费劲,拿Session来说,我们要在接口参数中加上HttpServletRequest对象,然后调用 getSession方法,且每一个需要用户信息的接口都要加上这个参数,才能获取Session,这样实现就很麻烦了。

在实际的系统设计中,我们肯定不会采用上面所说的这种方式,而是使用ThreadLocal,我们会选择在拦截器的业务中, 获取到保存的用户信息,然后存入ThreadLocal,那么当前线程在任何地方如果需要拿到用户信息都可以使用ThreadLocal的get()方法 (异步程序中ThreadLocal是不可靠的)

对于笔者而言,这个场景使用的比较多,当用户登录后,会将用户信息存入Token中返回前端,当用户调用需要授权的接口时,需要在header中携带 Token,然后拦截器中解析Token,获取用户信息,调用自定义的类(AuthNHolder)存入ThreadLocal中,当请求结束的时候,将ThreadLocal存储数据清空, 中间的过程无需在关注如何获取用户信息,只需要使用工具类的get方法即可。

public class AuthNHolder {
	private static final ThreadLocal<Map<String,String>> loginThreadLocal = new ThreadLocal<Map<String,String>>();

	public static void map(Map<String,String> map){
		loginThreadLocal.set(map);
	}
	public static String userId(){
    		return get("userId");
	}
	public static String get(String key){
    		Map<String,String> map = getMap();
    		return map.get(key);
    }
	public static void clear(){
       loginThreadLocal.remove();
	}
	
}

场景三:解决线程安全问题

在Spring的Web项目中,我们通常会将业务分为Controller层,Service层,Dao层, 我们都知道@Autowired注解默认使用单例模式,那么不同请求线程进来之后,由于Dao层使用单例,那么负责数据库连接的Connection也只有一个, 如果每个请求线程都去连接数据库,那么就会造成线程不安全的问题,Spring是如何解决这个问题的呢?

在Spring项目中Dao层中装配的Connection肯定是线程安全的,其解决方案就是采用ThreadLocal方法,当每个请求线程使用Connection的时候, 都会从ThreadLocal获取一次,如果为null,说明没有进行过数据库连接,连接后存入ThreadLocal中,如此一来,每一个请求线程都保存有一份 自己的Connection。于是便解决了线程安全问题

ThreadLocal在设计之初就是为解决并发问题而提供一种方案,每个线程维护一份自己的数据,达到线程隔离的效果。

14. 说一说自己对于 synchronized 关键字的了解?

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。

在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

  • 实例变量(Instance Data):存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

  • 填充数据(Padding):由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

  • 头对象(Object header):JVM中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:

虚拟机位数 头对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
  • Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
  • Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构:
锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

MarkWord

Monitor

Synchronized的对象锁(重量级锁),锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

Monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。

monitor

Synchronized代码块底层原理

代码块的同步是显式,使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置. 当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。

如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。

值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

Synchronized方法底层原理

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法. 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

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

为什么synchronized性能低下

因为存在和操作系统的交互,需要进行用户态(user mode)和内核态(Kernel mode)的转变。当遇到同步代码块的时候,程序会先保存上下文环境,然后去和操作系统交互,获得底层的互斥锁(Mutex Lock),然后在切会程序,恢复上下文环境,执行程序。在进行交互的时候,浪费了性能

before1.6

1.6的优化

首先某个锁会偏向某个线程,此时使用偏向锁。 如果有线程来竞争锁的话,那么锁会升级为轻量级锁。 如果多线程来竞争锁的话,会升级成上文所述的互斥锁,也就是重量级锁

偏向锁 -> 轻量级锁 -> 重量级锁(monitor)

  • 偏向锁
    • 表示锁对象偏向某个线程,线程可以判断对象锁是否偏向自己,OK的话可直接执行同步代码块
    • 对象默认开启偏向锁
    • 什么时候撤销偏向呢?
      1. 一个对象已经偏向了一个线程
      2. 另一个线程也要进入临界区,则撤销偏向锁
      3. 升级为轻量级锁

偏向锁

※偏向锁被被撤销之后还能再偏向吗?
当存在同一个类的多个对象被撤销的次数到达20次时,会执行批量重偏向。
比如: 以前好多锁偏向线程1,但后来线程2过来进行,撤销了偏向锁20次的话,那么这么锁对象就会偏向线程2了
偏向锁

  • 轻量级锁
    • 一个对象偏向一个线程,另一个线程也要进入临界区(同步代码块)
    • 撤销偏向锁,升级为轻量级锁
    • 使用CAS交换对象头以及锁记录
    • 如果CAS操作失败,说明有多线程竞争则升级为重量级锁

16. 谈谈 synchronized 和 ReenTrantLock 的区别?

1. 底层实现

底层实现上来说,synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法,ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。

synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁,ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

synchronized (new Object()){

}

new ReentrantLock();

使用javap -c对如上代码进行反编译得到如下代码:
svsr

2. 是否可以手动释放

synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。

private int number = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    private AtomicInteger atomicInteger;

    public void increment() throws Exception {
        lock.lock();
        try {

            while (number != 0) {
                condition.await();
            }
            //do something
            number++;
            System.out.println(Thread.currentThread().getName() + "\t" + number);
            condition.signalAll();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

    }

3. 是否可中断

synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void lockInterruptibly() throws InterruptedException {
    sync.acquireInterruptibly(1);
}

4. 是否公平锁

synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。

/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
    sync = new NonfairSync();
}

/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

5. 锁是否可以绑定条件

synchronized不能绑定; ReentrantLock通过绑定Condition结合await()/singal()方法实现线程的精确唤醒,而不是像synchronized通过Object类的wait()/notify()/notifyAll()方法要么随机唤醒一个线程要么唤醒全部线程。

6. 锁的对象

synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。

17. synchronized 和 volatile 的区别是什么?

Java内存模型(JMM)

提到这两个有关于线程的关键字,那么我们不得不提到Java的内存模型了(JMM),下面我们先看一下Java内存模型在处理多线程方面的工作原理图。
jmm
Java内存模型(java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

首先介绍两个概念

  • 可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。
  • 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

共享变量可见性实现的原理

线程1对共享变量的修改要想被线程2及时看到,必须要经过如下两个步骤

  1. 把工作内存1中更新过的共享变量刷新到主内存中
  2. 将主内存中最新的共享变量的值更新到工作内存2中

下图为一个共享变量实现可见性原理的一个示例:
shared

其中,线程对共享变量的操作,遵循一下两条规则

  1. 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写
  2. 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成

可见性

要实现共享变量的可见性,必须保证两点:

  1. 线程修改后的共享变量值能够及时从工作内存刷新到主内存中
  2. 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中

可见性的实现方式:

  1. synchronized
    JMM关于synchronized的两条规定:

    • 线程解锁前,必须把共享变量的最新值刷新到主内存中
    • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时,需要从主内存中重新读取最新的值(注意:加锁与解锁需要是同一把锁)
    • 注意:线程解锁前对共享变量的修改在下次加锁时对其他线程

    线程执行互斥代码的过程:

    • 获得互斥锁
    • 清空工作内存
    • 从主内存拷贝变量的最新副本到工作的内存
    • 执行代码
    • 将更改后的共享变量的值刷新到主内存
    • 释放互斥锁

    重排序

    代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化

    • 编译器优化的重排序(编译器优化)
    • 指令级并行重排序(处理器优化)
    • 内存系统的重排序(处理器优化)
  2. volatile实现可见性

    volatile如何实现内存可见性:

    • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令

      1.改变线程工作内存中volatile变量副本的值
      2.将改变后的副本的值从工作内存刷新到主内存

    • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令

      1.从主内存中读取volatile变量的最新值到线程的工作内存中
      2.从工作内存中读取volatile变量的副本

18. 谈一下你对 volatile 关键字的理解?

volatile是java提供的一种同步手段,只不过它是轻量级的同步,为什么这么说,因为volatile只能保证多线程的内存可见性,不能保证多线程的执行有序性。而要彻底的保证有序性和可见性需要使用synchronized等锁处理。

任何被volatile修饰的变量,都不拷贝副本到工作内存,任何修改都及时写在主存。因此对于Valatile修饰的变量的修改,所有线程马上就能看到,保证读取 volatile 变量可以观察到对此变量的最后一次写入。

volatile存在的意义是,任何线程对某个变量的修改,都会马上被其他线程读取到,因为直接操作主存,没有线程对工作内存和主存的同步。

volatile 的另一个保证是写入和读取 64 位值( long 类型和 double 类型 )的原子性。如果没有 volatile 修饰符,读取此类字段可能会观察到另一个线程部分写入的值。

什么场景下可以使用volatile替换synchronized?

只需要保证共享资源的可见性的时候可以使用volatile替代,synchronized保证可操作的原子性一致性和可见性。volatile适用于新值不依赖于就值的情形。
要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
1)对变量的写操作不依赖于当前值。
2)该变量没有包含在具有其他变量的不变式中

19. 说下对悲观锁和乐观锁的理解?

基本概念

乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。

  • 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
  • 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。

实现方式

在说明实现方式之前,需要明确:乐观锁和悲观锁是两种思想,它们的使用是非常广泛的,不局限于某种编程语言或数据库。

悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。

乐观锁的实现方式主要有两种:CAS机制和版本号机制,下面详细介绍。

  1. CAS(Compare And Swap)

CAS操作包括了3个操作数:

需要读写的内存位置(V)
进行比较的预期值(A)
拟写入的新值(B)
CAS操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。

这里引出一个新的问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

下面以Java中的自增操作(i++)为例,看一下悲观锁和CAS分别是如何保证线程安全的。我们知道,在Java中自增操作不是原子操作,它实际上包含三个独立的操作:(1)读取i值;(2)加1;(3)将新值写回i

因此,如果并发执行自增操作,可能导致计算结果的不准确。在下面的代码示例中:value1没有进行任何线程安全方面的保护,value2使用了乐观锁(CAS),value3使用了悲观锁(synchronized)。运行程序,使用1000个线程同时对value1、value2和value3进行自增操作,可以发现:value2和value3的值总是等于1000,而value1的值常常小于1000。

public class Test {
     
    //value1:线程不安全
    private static int value1 = 0;
    //value2:使用乐观锁
    private static AtomicInteger value2 = new AtomicInteger(0);
    //value3:使用悲观锁
    private static int value3 = 0;
    private static synchronized void increaseValue3(){
        value3++;
    }
     
    public static void main(String[] args) throws Exception {
        //开启1000个线程,并执行自增操作
        for(int i = 0; i < 1000; ++i){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    value1++;
                    value2.getAndIncrement();
                    increaseValue3();
                }
            }).start();
        }
        //打印结果
        Thread.sleep(1000);
        System.out.println("线程不安全:" + value1);
        System.out.println("乐观锁(AtomicInteger):" + value2);
        System.out.println("悲观锁(synchronized):" + value3);
    }
}

首先来介绍AtomicInteger。AtomicInteger是java.util.concurrent.atomic包提供的原子类,利用CPU提供的CAS操作来保证原子性;除了AtomicInteger外,还有AtomicBoolean、AtomicLong、AtomicReference等众多原子类。

下面看一下AtomicInteger的源码,了解下它的自增操作getAndIncrement()是如何实现的(源码以Java7为例,Java8有所不同,但思想类似)。

public class AtomicInteger extends Number implements java.io.Serializable {
    //存储整数值,volatile保证可视性
    private volatile int value;
    //Unsafe用于实现对底层资源的访问
    private static final Unsafe unsafe = Unsafe.getUnsafe();
 
    //valueOffset是value在内存中的偏移量
    private static final long valueOffset;
    //通过Unsafe获得valueOffset
    static {
        try {
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
 
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
 
    public final int getAndIncrement() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
    }
}

源码分析说明如下:

(1)getAndIncrement()实现的自增操作是自旋CAS操作:在循环中进行compareAndSet,如果执行成功则退出,否则一直执行。

(2)其中compareAndSet是CAS操作的核心,它是利用Unsafe对象实现的。

(3)Unsafe又是何许人也呢?Unsafe是用来帮助Java访问操作系统底层资源的类(如可以分配内存、释放内存),通过Unsafe,Java具有了底层操作能力,可以提升运行效率;强大的底层资源操作能力也带来了安全隐患(类的名字Unsafe也在提醒我们这一点),因此正常情况下用户无法使用。AtomicInteger在这里使用了Unsafe提供的CAS功能。

(4)valueOffset可以理解为value在内存中的偏移量,对应了CAS三个操作数(V/A/B)中的V;偏移量的获得也是通过Unsafe实现的。

(5)value域的volatile修饰符:Java并发编程要保证线程安全,需要保证原子性、可视性和有序性;CAS操作可以保证原子性,而volatile可以保证可视性和一定程度的有序性;在AtomicInteger中,volatile和CAS一起保证了线程安全性。关于volatile作用原理的说明涉及到Java内存模型(JMM),这里不详细展开。

说完了AtomicInteger,再说synchronized。synchronized通过对代码块加锁来保证线程安全:在同一时刻,只能有一个线程可以执行代码块中的代码。synchronized是一个重量级的操作,不仅是因为加锁需要消耗额外的资源,还因为线程状态的切换会涉及操作系统核心态和用户态的转换;不过随着JVM对锁进行的一系列优化(如自旋锁、轻量级锁、锁粗化等),synchronized的性能表现已经越来越好。

  1. 版本号机制

除了CAS,版本号机制也可以用来实现乐观锁。版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。

下面以“更新玩家金币数”为例(数据库为MySQL,其他数据库同理),看看悲观锁和版本号机制是如何应对并发问题的。

考虑这样一种场景:游戏系统需要更新玩家的金币数,更新后的金币数依赖于当前状态(如金币数、等级等),因此更新前需要先查询玩家当前状态。

下面的实现方式,没有进行任何线程安全方面的保护。如果有其他线程在query和update之间更新了玩家的信息,会导致玩家金币数的不准确。

@Transactional
public void updateCoins(Integer playerId){
    //根据player_id查询玩家信息
    Player player = query("select coins, level from player where player_id = {0}", playerId);
    //根据玩家当前信息及其他信息,计算新的金币数
    Long newCoins = ……;
    //更新金币数
    update("update player set coins = {0} where player_id = {1}", newCoins, playerId);
}

为了避免这个问题,悲观锁通过加锁解决这个问题,代码如下所示。在查询玩家信息时,使用select …… for update进行查询;该查询语句会为该玩家数据加上排它锁,直到事务提交或回滚时才会释放排它锁;在此期间,如果其他线程试图更新该玩家信息或者执行select for update,会被阻塞。

@Transactional
public void updateCoins(Integer playerId){
    //根据player_id查询玩家信息(加排它锁)
    Player player = queryForUpdate("select coins, level from player where player_id = {0} for update", playerId);
    //根据玩家当前信息及其他信息,计算新的金币数
    Long newCoins = ……;
    //更新金币数
    update("update player set coins = {0} where player_id = {1}", newCoins, playerId);
}

版本号机制则是另一种思路,它为玩家信息增加一个字段:version。在初次查询玩家信息时,同时查询出version信息;在执行update操作时,校验version是否发生了变化,如果version变化,则不进行更新。

@Transactional
public void updateCoins(Integer playerId){
    //根据player_id查询玩家信息,包含version信息
    Player player = query("select coins, level, version from player where player_id = {0}", playerId);
    //根据玩家当前信息及其他信息,计算新的金币数
    Long newCoins = ……;
    //更新金币数,条件中增加对version的校验
    update("update player set coins = {0}, version = version + 1 where player_id = {1} and version = {2}", newCoins, playerId, player.version);
}

优缺点和适用场景

乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;下面从两个方面进行说明。

  1. 功能限制

与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。

例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

  1. 竞争激烈程度
    如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
  • 当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
  • 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

乐观锁加锁吗?

笔者在面试时,曾遇到面试官如此追问。下面是我对这个问题的理解:

(1)乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了;AtomicInteger便是一个例子。

(2)有时乐观锁可能与加锁操作合作,例如,在前述updateCoins()的例子中,MySQL在执行update时会加排它锁。但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”这一事实。

CAS有哪些缺点?

  1. ABA问题

假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:

(1)线程1读取内存中数据为A;

(2)线程2将该数据修改为B;

(3)线程2将该数据修改为A;

(4)线程1对数据进行CAS操作

在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。

在AtomicInteger的例子中,ABA似乎没有什么危害。但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。

对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

  1. 高竞争下的开销问题

在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。

  1. 功能限制

CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:
(1)原子性不一定能保证线程安全,例如在Java中需要与volatile配合来保证线程安全;
(2)当涉及到多个变量(内存值)时,CAS也无能为力。

除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。

20. CAS 和 synchronized 的使用场景?

CAS简单介绍

Compare And Swap,比较和交换,在JDK1.8的环境下,定义在Unsafe类下用于比较,交换元素,以及重新尝试(该过程称为自旋)
例如,m=1,此时一个线程使用CAS对该m执行自增加一操作,此时期望值是m本身1,新值是自增后的值2,比较:m==期望值,执行交换:m=NewValue,但如果此时在比较之前,一个新线程对m执行了一个自增加一的操作,m变为2,但m的期望值仍然为1,在比较时,由于m!=Expected,就不会执行交换,而是执行自旋(重试)操作,此时m的期望值变为2,NewValue变为3,重新执行与m的比较和交换,这就是CAS的基本操作流程,自旋是一个消耗CPU运行效率的操作。

Synchronized简单介绍

在JDK1.5后,当使用了Sync锁时,被锁定对象的对象头上会有两个bit位记载了锁标志位,一个bit记载了是否为偏向锁。在一个线程A的情况下时,此时为偏向锁,会在对象头上会记载线程ID,当出现线程竞争,判断对象头上记载的ID是否指向当前线程,如果不为当前线程,查看对象头中的线程A是否存活,如果存活且栈帧中仍有锁定对象的引用,此时升级为自旋锁,一个线程占据CPU执行,其余线程自旋等待执行,当自旋十次后如果仍未执行,就会去OS操作系统中申请重量级锁,进入CPU的等待队列中等待执行,在等待队列中并不会消耗CPU运行效率。锁升级过程为无锁->偏向锁->自旋锁->重量级锁。

总结

当线程数量多,执行时间长,用重量级锁。
当线程数量少,执行时间短,用轻量级锁。

21. atomic 的原理是什么?

铺垫

public class AtomicMain {

    public static void main(String[] args) throws InterruptedException {

        ExecutorService service = Executors.newCachedThreadPool();

        Count count = new Count();
        // 100个线程对共享变量进行加1
        for (int i = 0; i < 100; i++) {
            service.execute(() -> count.increase());
        }

        // 等待上述的线程执行完
        service.shutdown();
        service.awaitTermination(1, TimeUnit.DAYS);

        System.out.println(count.getCount());
    }

}

class Count{

    // 共享变量
    private Integer count = 0;
    public Integer getCount() {
        return count;
    }
    public  void increase() {
        count++;
    }
}

根据结果我们得知:上面的代码是线程不安全的!如果线程安全的代码,多次执行的结果是一致的!

我们可以发现问题所在:count++并不是原子操作。因为count++需要经过读取-修改-写入三个步骤。举个例子:

  • 如果某一个时刻:线程A读到count的值是10
  • 线程B读到count的值也是10线程A对count++
  • 此时count的值为11线程B对count++
  • 此时count的值也是11(因为线程B读到的count是10)

所以到这里应该知道为啥我们的结果是不确定了吧。

要将上面的代码变成线程安全的(每次得出的结果是100),那也很简单,毕竟我们是学过synchronized锁的人:

在increase()加synchronized锁就好了

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

从上面的代码我们也可以发现,只做一个++这么简单的操作,都用到了synchronized锁,未免有点小题大做了。
Synchronized锁是独占的,意味着如果有别的线程在执行,当前线程只能是等待! 于是我们原子变量的类就登场了!

  1. CAS

CAS有3个操作数:

  • 内存值
  • V旧的预期值
  • A要修改的新值B

当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值(A和内存值V相同时,将内存值V修改为B),而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试(或者什么都不做)。我们画张图来理解一下:

CAS失败重试(自旋) retry

CAS失败重试(什么也不做) stop

理解CAS的核心就是:CAS是原子性的,虽然你可能看到比较后再修改(compare and swap)觉得会有两个操作,但终究是原子性的!

  1. 原子变量类简单介绍
    atomic

分类:

  • 基本类型

    • AtomicBoolean:布尔型
    • AtomicInteger:整型
    • AtomicLong:长整型
  • 数组

    • AtomicIntegerArray:数组里的整型
    • AtomicLongArray:数组里的长整型
    • AtomicReferenceArray:数组里的引用类型
  • 引用类型

    • AtomicReference:引用类型
    • AtomicStampedReference:带有版本号的引用类型
    • AtomicMarkableReference:带有标记位的引用类型
  • 对象的属性

    • AtomicIntegerFieldUpdater:对象的属性是整型
    • AtomicLongFieldUpdater:对象的属性是长整型
    • AtomicReferenceFieldUpdater:对象的属性是引用类型
  • JDK8新增DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder是对AtomicLong等类的改进。比如LongAccumulator与LongAdder在高并发环境下比AtomicLong更高效。

Atomic包里的类基本都是使用Unsafe实现的包装类。
Unsafe里边有几个我们喜欢的方法(CAS):

// 第一和第二个参数代表对象的实例以及地址,第三个参数代表期望值,第四个参数代表更新值
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

从原理上概述就是:Atomic包的类的实现绝大调用Unsafe的方法,而Unsafe底层实际上是调用C代码,C代码调用汇编,最后生成出一条CPU指令cmpxchg,完成操作。这也就为啥CAS是原子性的,因为它是一条CPU指令,不会被打断。

ABA问题

使用CAS有个缺点就是ABA的问题,什么是ABA问题呢?
首先我用文字描述一下:

  • 现在我有一个变量count=10,现在有三个线程,分别为A、B、C
  • 线程A和线程C同时读到count变量,所以线程A和线程C的内存值和预期值都为10
  • 此时线程A使用CAS将count值修改成100
  • 修改完后,就在这时,线程B进来了,读取得到count的值为100(内存值和预期值都是100),将count值修改成10
  • 线程C拿到执行权,发现内存值是10,预期值也是10,将count值修改成11

上面的操作都可以正常执行完的,这样会发生什么问题呢??线程C无法得知线程A和线程B修改过的count值,这样是有风险的。下面我再画个图来说明一下ABA的问题(以链表为例):

aba

解决ABA问题

要解决ABA的问题,我们可以使用JDK给我们提供的AtomicStampedReference和AtomicMarkableReference类。

简单来说就是在给为这个对象提供了一个版本,并且这个版本如果被修改了,是自动更新的。

原理大概就是:维护了一个Pair对象,Pair对象存储我们的对象引用和一个stamp值。每次CAS比较的是两个Pair对象

// Pair对象
    private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;

    // 比较的是Pari对象
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

因为多了一个版本号比较,所以就不会存在ABA的问题了。

22. 说下对同步器 AQS 的理解?

什么是AQS

AQS是AbstractQueuedSynchronizer类的简称,从名字我们可以猜测,AQS是一个虚拟类,基于队列实现,用于构建同步器。这个猜测是正确的,它是JDK提供的一个基于FIFO等待队列实现阻塞锁及相关同步器(Semaphore等)的一个框架,它是大多数同步器的基础,用一个volatile的int类型变量state来表示状态。

AQS的核心设计思想是,如果当前线程请求的共享资源处于闲置状态,则将该线程设置为工作线程,并将该资源设置为锁定状态。如果该资源处于锁定状态,则将该线程加入CLH队列中。

那么问题来了!什么是CLH队列?无需解释,看完下图就全明白了。

aqs

源码分析

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {

    ......

    private volatile int state;

    protected final int getState() {
        return state;
    }

    protected final void setState(int newState) {
        state = newState;
    }
    
    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

    ......

}

AQS定义了两种资源共享方式,独占锁与共享锁。所谓的独占锁就是,只有一个线程可以执行,比如之前谈过的ReentrantLock就是独占锁的一种,而共享锁,就是多个线程可以执行,比如上文提到的Semaphore。在此就不展开。

以ReentrantLock为例,我们可以学习基于AQS的公平锁与非公平锁的实现。我们看一下ReentrantLock类中相关部分的源码,首先,我们看一下它的构造方法。

public class ReentrantLock implements Lock, java.io.Serializable {
    
    ......
    
    private final Sync sync;
    
    ......
    
    public ReentrantLock() {
        sync = new NonfairSync();
    }

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

如源码所示,ReentrantLock的默认构造函数是非公平锁。接下来,我们看一下NonfairSync类与FairSync类的不同之处。首先,我们看一下公平锁的实现。

public class ReentrantLock implements Lock, java.io.Serializable {

    ......
    
    // 非公平锁
    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;
        
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

    // 公平锁
    static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }
    
}

可以看出,非公平锁和公平锁主要有以下不同:

  • 二者的lock方法不同,非公平锁在调用lock方法后,会CAS抢占锁,如果锁没有被占用,则直接获取。

  • 非公平锁在CAS失败后,会同公平锁一样进入到tryAcquire方法,如果发现锁被释放(state == 0),非公平锁会CAS抢占锁,而公平锁则会检查等待队列是否有处于等待状态的线程。

模板方法模式

同步器的设计是基于模板方法模式的,从ReentrantLock的源码我们也可以看出,自定义同步器方式大致如下:

  • 使用者继承AQS并重写指定的方法。

  • 将AQS以组合的形式引入自定义同步组件的实现中,并调用其模板方法,模板方法会调用重写的方法。

  • 具体需要重写的方法可以参考ReentrantLock的源码,在此便不再赘述。

23. 说下对信号量 Semaphore 的理解?

信号量 想想以下有一大片麦子,线程就是工人,5个工人,要割麦子必须要用到镰刀,那么这个镰刀就是信号量。加入有三把镰刀,那么同时就只能有三个线程进行工作。 如果只有一把镰刀,那么这个信号量就是锁

Semaphore是一个计数信号量,常用于限制可以访问某些资源(物理或逻辑的)线程数目。
一个信号量有且仅有3种操作,且它们全部是原子的:初始化、增加和减少 
增加可以为一个进程解除阻塞; 
减少可以让一个进程进入阻塞。
和线程池的区别:使用Seamphore,创建了多少线程,实际就会有多少线程进行执行,只是可同时执行的线程数量会受到限制。但使用线程池,不管你创建多少线程,实际可执行的线程数是一定的。

  1. 构造方法

Semaphore(int)、Semaphore(int,boolean)
int表示该信号量拥有的许可数量
boolean表示获取许可的时候是否是公平的。(公平指的是先来的先执行)

  1. 获取许可

acquire()、acquire(int)、tryAcquire()
int参数表示一次性要获取几个许可,默认为1个,acquire方法在没有许可的情况下,要获取许可的线程会阻塞。
tryAcquire()方法在没有许可的情况下会立即返回 false,要获取许可的线程不会阻塞。

  1. 释放许可

release()、release(int)
int参数表示一次性要释放几个许可,默认为1个,
注意一个线程调用release()之前并不要求一定要调用了acquire因此如果释放的比获取的信号量还多,例如获取了2个,释放了5次,那么当前信号量就动态的增加为5了(实现动态增加)

  1. 当前可用的许可数

int availablePermits()

24. CountDownLatch 和 CyclicBarrier 有什么区别?

https://www.yuque.com/lexiao-1kmgg/ah8dgx/hob9la#jYzPZ

25. 说下对线程池的理解?为什么要使用线程池?

为什么使用线程池

  • 降低资源消耗。重复利用已创建线程,降低线程创建与销毁的资源消耗。(尤其是当程序中需要创建大量生存期很短暂的线程时)
  • 提高响应效率。任务到达时,不需等待创建线程就能立即执行。
  • 提高线程可管理性。
  • 防止服务器过载。内存溢出、CPU耗尽

原理

线程池和数据库连接池有点类似的是,线程池在系统启动时创建大量空闲线程,程序将一个Runnable对象传给线程池,线程池就会启动一条线程来执行该线程对象的run方法,当run方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲线程,等待执行下一个Runnable对象的run方法。

优点

使用线程池可以有效的控制系统中并发线程的数量,当系统中包含大量的并发线程时,会导致系统性能剧烈下降,甚至导致JVM的崩溃,而线程池的最大线程参数可以控制系统中并发线程数目不超过此数目。

线程池种类

ExecutorService代表尽快执行线程的线程池(只要线程中有空闲的线程就立即执行线程任务)。 ScheduledExecutorService代表可在指定延迟或周期性执行线程任务的线程池。

pool

ThreadPoolExecutor线程池参数

  • corePoolSize - 池中所保存的线程数,包括空闲线程。
  • maximumPoolSize - 池中允许的最大线程数。
  • keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
  • unit - keepAliveTime 参数的时间单位。
  • workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute 方法提交的 Runnable 任务。
  • threadFactory - 执行程序创建新线程时使用的工厂。
  • handler - 由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。

线程池的主要处理流程

flow

1.在创建了线程池之后等待提交过来的任务请求
2.当调用execute()方法添加一个请求任务的时候线程池会做出如下判断2.1 如果正在运行的线程数量小于corePoolSize那么马上创建线程运行这个程序
2.2 如果正在运行的线程数量大于或者等于corePoolSize那么将这个任务放入队列
2.3 如果这个时候队列满了并且正在运行的线程数量还小于maximumPoolSize那么还是要创建非核心线程立刻执行这个任务
2.4 如果队列满了并且正在运行的线程数量大于或等于maximumPoolSize那么线程池会启动饱和拒绝策略来执行
3. 当一个线程完成任务的时候它会从队列中取下一个任务来执行
4. 当一个线程无事可做超过一定时间keepAliveTime线程会判断如果当前运行的线程数
    大于corePoolSize那么这个线程就会被停掉

阻塞队列

queue

线程池有哪些拒绝策略

reject

26. 如何创建线程池?

线程池的创建方法总共有 7 种,但总体来说可分为 2 类:

  • 一类是通过 ThreadPoolExecutor 创建的线程池;
  • 另一个类是通过 Executors 创建的线程池。

1. FixedThreadPool

创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待。

public static void fixedThreadPool() {
    // 创建 2 个数据级的线程池
    ExecutorService threadPool = Executors.newFixedThreadPool(2);

    // 创建任务
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
        }
    };

    // 线程池执行任务(一次添加 4 个任务)
    // 执行任务的方法有两种:submit 和 execute
    threadPool.submit(runnable);  // 执行方式 1:submit
    threadPool.execute(runnable); // 执行方式 2:execute
    threadPool.execute(runnable);
    threadPool.execute(runnable);
}

2. CachedThreadPool

创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。

public static void cachedThreadPool() {
    // 创建线程池
    ExecutorService threadPool = Executors.newCachedThreadPool();
    // 执行任务
    for (int i = 0; i < 10; i++) {
        threadPool.execute(() -> {
            System.out.println("任务被执行,线程:" + Thread.currentThread().getName());
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
        });
    }
}

3. SingleThreadExecutor

创建单个线程数的线程池,它可以保证先进先出的执行顺序。

public static void singleThreadExecutor() {
    // 创建线程池
    ExecutorService threadPool = Executors.newSingleThreadExecutor();
    // 执行任务
    for (int i = 0; i < 10; i++) {
        final int index = i;
        threadPool.execute(() -> {
            System.out.println(index + ":任务被执行");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
        });
    }
}

4. ScheduledThreadPool

创建一个可以执行延迟任务的线程池。

public static void scheduledThreadPool() {
    // 创建线程池
    ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(5);
    // 添加定时执行任务(1s 后执行)
    System.out.println("添加任务,时间:" + new Date());
    threadPool.schedule(() -> {
        System.out.println("任务被执行,时间:" + new Date());
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
    }, 1, TimeUnit.SECONDS);
}

5. SingleThreadScheduledExecutor

创建一个单线程的可以执行延迟任务的线程池。

public static void SingleThreadScheduledExecutor() {
    // 创建线程池
    ScheduledExecutorService threadPool = Executors.newSingleThreadScheduledExecutor();
    // 添加定时执行任务(2s 后执行)
    System.out.println("添加任务,时间:" + new Date());
    threadPool.schedule(() -> {
        System.out.println("任务被执行,时间:" + new Date());
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
    }, 2, TimeUnit.SECONDS);
}

6. newWorkStealingPool

创建一个抢占式执行的线程池(任务执行顺序不确定),注意此方法只有在 JDK 1.8+ 版本中才能使用。

public static void workStealingPool() {
    // 创建线程池
    ExecutorService threadPool = Executors.newWorkStealingPool();
    // 执行任务
    for (int i = 0; i < 10; i++) {
        final int index = i;
        threadPool.execute(() -> {
            System.out.println(index + " 被执行,线程名:" + Thread.currentThread().getName());
        });
    }
    // 确保任务执行完成
    while (!threadPool.isTerminated()) {
    }
}

7. ThreadPoolExecutor

最原始的创建线程池的方式,它包含了 7 个参数可供设置。

public static void myThreadPoolExecutor() {
    // 创建线程池
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10));
    // 执行任务
    for (int i = 0; i < 10; i++) {
        final int index = i;
        threadPool.execute(() -> {
            System.out.println(index + " 被执行,线程名:" + Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

ThreadPoolExecutor 参数介绍
ThreadPoolExecutor 最多可以设置 7 个参数,如下代码所示:

 public ThreadPoolExecutor(int corePoolSize,
                           int maximumPoolSize,
                           long keepAliveTime,
                           TimeUnit unit,
                           BlockingQueue<Runnable> workQueue,
                           ThreadFactory threadFactory,
                           RejectedExecutionHandler handler) {
     // 省略...
 }

7 个参数代表的含义如下:

参数 1:corePoolSize
核心线程数,线程池中始终存活的线程数。

参数 2:maximumPoolSize
最大线程数,线程池中允许的最大线程数,当线程池的任务队列满了之后可以创建的最大线程数。

参数 3:keepAliveTime
最大线程数可以存活的时间,当线程中没有任务执行时,最大线程就会销毁一部分,最终保持核心线程数量的线程。

参数 4:unit:
单位是和参数 3 存活时间配合使用的,合在一起用于设定线程的存活时间 ,参数 keepAliveTime 的时间单位有以下 7 种可选:

TimeUnit.DAYS:天
TimeUnit.HOURS:小时
TimeUnit.MINUTES:分
TimeUnit.SECONDS:秒
TimeUnit.MILLISECONDS:毫秒
TimeUnit.MICROSECONDS:微妙
TimeUnit.NANOSECONDS:纳秒

参数 5:workQueue
一个阻塞队列,用来存储线程池等待执行的任务,均为线程安全,它包含以下 7 种类型:

ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

较常用的是 LinkedBlockingQueue 和 Synchronous,线程池的排队策略与 BlockingQueue 有关。

参数 6:threadFactory
线程工厂,主要用来创建线程,默认为正常优先级、非守护线程。

参数 7:handler
拒绝策略,拒绝处理任务时的策略,系统提供了 4 种可选:

AbortPolicy:拒绝并抛出异常。
CallerRunsPolicy:使用当前调用的线程来执行此任务。
DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
DiscardPolicy:忽略并抛弃当前任务。
默认策略为 AbortPolicy。

究竟选用哪种线程池?

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

说明:Executors 返回的线程池对象的弊端如下:

1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

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

https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html

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

  1. execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

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

28. 说下对 Fork和Join 并行计算框架的理解?

fork分离
join合并
其实就是分治思想

这个框架被设计用来解决可以使用分而治之技术将任务分解成更小的问题。

  • fork操作:当你把任务分成更小的任务和使用这个框架执行它们。
  • join操作:当一个任务等待它创建的任务的结束。

forkjoin

Fork Join 框架优势

  • fork/join 框架允许并行化递归算法。使用 ThreadPoolExecutor 之类的并行递归的主要问题是,可能会快速耗尽线程,因为每个递归步骤都需要自己的线程,而堆栈中的线程将处于空闲状态并等待。

  • fork/join 框架入口点是 ForkJoinPool 类,它是 ExecutorService 的一个实现。它实现了工作窃取算法(work-stealing),空闲线程会试图从忙线程中 「 窃取 」 工作。这允许在不同线程之间传播计算并在使用比通常的线程池所需的更少的线程时取得进展.

Fork/Join框架局限性

  • 任务只能使用fork()和join()操作,作为同步机制。如果使用其他同步机制,工作线程不能执行其他任务,当它们在同步操作时。比如,在Fork/Join框架中,你使任务进入睡眠,正在执行这个任务的工作线程将不会执行其他任务,在这睡眠期间内。
  • 任务不应该执行I/O操作,如读或写数据文件。
  • 任务不能抛出检查异常,它必须包括必要的代码来处理它们。

5. JVM

1. 说一下 Jvm 的主要组成部分?及其作用?

内存模型

Java内存模型,往往是指Java程序在运行时内存的模型,而Java代码是运行在Java虚拟机之上的,由Java虚拟机通过解释执行(解释器)或编译执行(即时编译器)来完成,故Java内存模型,也就是指Java虚拟机的运行时内存模型。

作为Java开发人员来说,并不需要像C/C++开发人员,需要时刻注意内存的释放,而是全权交给虚拟机去管理,那么有就必要了解虚拟机的运行时内存是如何构成的。运行时内存模型,分为线程私有和共享数据区两大类,其中线程私有的数据区包含程序计数器、虚拟机栈、本地方法区,所有线程共享的数据区包含Java堆、方法区,在方法区内有一个常量池。

jvm

(1)线程私有区:

程序计数器,记录正在执行的虚拟机字节码的地址;
虚拟机栈:方法执行的内存区,每个方法执行时会在虚拟机栈中创建栈帧(Stack Frame);
本地方法栈:虚拟机的Native方法执行的内存区;
(2)线程共享区:

Java堆:对象分配内存的区域;
方法区:存放类信息、常量、静态变量、编译器编译后的代码等数据;
常量池:存放编译器生成的各种字面量和符号引用,是方法区的一部分。 对于大多数的程序员来说,Java内存比较流行的说法便是堆和栈,这其实是非常粗略的一种划分,这种划分的”堆”对应内存模型的Java堆,”栈”是指虚拟机栈,然而Java内存模型远比这更复杂,想深入了解Java的内存,还是有必要明白整个内存模型。

详细模型

运行时内存分为五大块区域(常量池属于方法区,算作一块区域),前面简要介绍了每个区域的功能,那接下来再详细说明每个区域的内容,Java内存总体结构图如下:

detail

  1. 程序计数器PC

程序计数器PC,当前线程所执行的字节码行号指示器。每个线程都有自己计数器,是私有内存空间,该区域是整个内存中较小的一块。

当线程正在执行一个Java方法时,PC计数器记录的是正在执行的虚拟机字节码的地址;当线程正在执行的一个Native方法时,PC计数器则为空(Undefined)。

  1. 虚拟机栈

虚拟机栈,生命周期与线程相同,是Java方法执行的内存模型。每个方法(不包含native方法)执行的同时都会创建一个栈帧结构,方法执行过程,对应着虚拟机栈的入栈到出栈的过程。

栈帧(Stack Frame)结构

栈帧是用于支持虚拟机进行方法执行的数据结构,是属性运行时数据区的虚拟机站的栈元素。见上图, 栈帧包括:

局部变量表 (locals大小,编译期确定),一组变量存储空间, 容量以slot为最小单位。

操作栈(stack大小,编译期确定),操作栈元素的数据类型必须与字节码指令序列严格匹配

动态连接, 指向运行时常量池中该栈帧所属方法的引用,为了 动态连接使用。
- 前面的解析过程其实是静态解析;
- 对于运行期转化为直接引用,称为动态解析。

方法返回地址
- 正常退出,执行引擎遇到方法返回的字节码,将返回值传递给调用者
- 异常退出,遇到Exception,并且方法未捕捉异常,那么不会有任何返回值。

额外附加信息,虚拟机规范没有明确规定,由具体虚拟机实现。

异常(Exception)
Java虚拟机规范规定该区域有两种异常:

  • StackOverFlowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出
  • OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出

本地方法栈

本地方法栈则为虚拟机使用到的Native方法提供内存空间,而前面讲的虚拟机栈式为Java方法提供内存空间。有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如非常典型的Sun HotSpot虚拟机。

异常(Exception):Java虚拟机规范规定该区域可抛出StackOverFlowError和OutOfMemoryError。

Java堆

Java堆,是Java虚拟机管理的最大的一块内存,也是GC的主战场,里面存放的是几乎所有的对象实例和数组数据。JIT编译器有栈上分配、标量替换等优化技术的实现导致部分对象实例数据不存在Java堆,而是栈内存。

  • 从内存回收角度,Java堆被分为新生代和老年代;这样划分的好处是为了更快的回收内存;
  • 从内存分配角度,Java堆可以划分出线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB);这样划分的好处是为了更快的分配内存;

对象创建的过程是在堆上分配着实例对象,那么对象实例的具体结构如下:

object

对于填充数据不是一定存在的,仅仅是为了字节对齐。HotSpot VM的自动内存管理要求对象起始地址必须是8字节的整数倍。对象头本身是8的倍数,当对象的实例数据不是8的倍数,便需要填充数据来保证8字节的对齐。该功能类似于高速缓存行的对齐。

另外,关于在堆上内存分配是并发进行的,虚拟机采用CAS加失败重试保证原子操作,或者是采用每个线程预先分配TLAB内存.

异常(Exception):Java虚拟机规范规定该区域可抛出OutOfMemoryError。

方法区

方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。GC在该区域出现的比较少。

异常(Exception):Java虚拟机规范规定该区域可抛出OutOfMemoryError。

运行时常量池

运行时常量池也是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。运行时常量池除了编译期产生的Class文件的常量池,还可以在运行期间,将新的常量加入常量池,比较常见的是String类的intern()方法。

字面量:与Java语言层面的常量概念相近,包含文本字符串、声明为final的常量值等。
符号引用:编译语言层面的概念,包括以下3类:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符 但是该区域不会抛出OutOfMemoryError异常。

2. 谈谈对运行时数据区的理解?

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。 Java虚拟机所管理的内存将会包括以下几个运行时数据区域:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区

1. 程序计数器

是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

2. Java虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的
内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法 从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

3. 本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机 执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
Java堆:Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

4. Java堆

是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。从内存回收的角度来看,由于现在收集器基本都采用 分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。

5. 方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后 的代码等数据。 (在Java编程语言和环境中,即时编译器(JIT compiler,just-in-timecompiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器(rocessor)的指令的程序。)

jit

首先Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀), 然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。 在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息, 这段空间一般被称作为Runtime Data Area(运行时数据区)

Java 虚拟机在执行Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。 这些区域都有各自的用途,以及创建和销毁的时间,有点区域随着虚拟机进程的启动而存在, 有些区域则依赖用户线程的启动和结束而建立和销毁。

3. 堆和栈的区别是什么?

区别

  • 功能

    • 栈内存用来存储局部变量和方法调用。
    • 而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
  • 共享性不同

    • 栈内存是线程私有的。
    • 堆内存是所有线程共有的。
  • 异常错误不同
    如果栈内存或者堆内存不足都会抛出异常。

    • 栈空间不足:java.lang.StackOverFlowError。
    • 堆空间不足:java.lang.OutOfMemoryError。
  • 空间大小
    栈的空间大小远远小于堆的。

4. 堆中存什么?栈中存什么?

堆中存的是对象,栈中存的是基本数据类型和堆中对象的引用,一个对象的大小不可估计或者说可以动态变化的,但是在栈中,一个对象只对应一个4byte的引用。 为啥不把基本类型放在堆中呢?因为其占用的空间一般是1~8个字节—需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况,长度固定,因此栈中存储就够了,如果把他存在堆中没有什么意义。可以说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据,下面是一个常见的问题是。

5. 为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?

  1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰,分而治之的思想。
  2. 堆和栈的分离,使得堆中的内容可以被多个栈共享,一方面这种共享提供了一种有效的数据交互方式,另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
  3. 栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分,由于栈只能向上增长,因此就会限制住栈存储的内容能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能。相应的栈中只需要记录堆中的一个地址即可。
  4. 面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑。

6. Java 中的参数传递时传值呢?还是传引用?

Java在方法调用传入参数时,因为没有指针,所以它都是进行传值调用,基本类型和引用类型的处理是一样的,都是传值。所以,如果是传引用的方法调用,可以理解为传引用值的传值调用,即引用的处理和基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序查找到堆中的对象,这个时候对应到真正的对象,如果此时进行修改,修改的就是引用对应的对象,而不是引用本身,即:修改的是堆中的数据,所以这个修改是可以保持的。 对象,,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。 程序参数传递时,被传递的值本身都是不能修改的,但是如果这个值是一个非叶子节点(即一个对象引用),则是可以修改这个节点下面的所有内容的。

7. Java 对象的大小是怎么计算的?

基本数据的类型的大小是固定的。对于非基本类型的 Java 对象,其大小就值得商榷。在 Java 中,一个空 Object 对象的大小是 8 byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句:

Object ob = new Object();

这样在程序中完成了一个 Java 对象的生命,但是它所占的空间为:4 byte + 8 byte。4 byte 是上面部分所说的 Java 栈中保存引用的所需要的空间。而那 8 byte 则是 Java 堆中对象的信息。因为所有的 Java 非基本类型的对象都需要默认继承 Object 对象,因此不论什么样的 Java 对象,其大小都必须是大于 8 byte。有了 Object 对象的大小,我们就可以计算其他对象的大小了。

Class MaNong { 
    int count;
    boolean flag;
    Object obj; 
}

MaNong 的大小为:空对象大小(8 byte) + int 大小(4 byte) + Boolean 大小(1 byte) + 空 Object 引用的大小(4 byte) = 17byte。但是因为 Java 在对对象内存分配时都是以 8 的整数倍来分,因此大于 17 byte 的最接近 8 的整数倍的是 24,因此此对象的大小为 24 byte。

这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把它们作为对象来看待。包装类型的大小至少是12 byte(声明一个空 Object 至少需要的空间),而且 12 byte 没有包含任何有效信息,同时,因为 Java 对象大小是 8 的整数倍,因此一个基本类型包装类的大小至少是 16 byte。这个内存占用是很恐怖的,它是使用基本类型的 N 倍(N > 2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在 JDK5 以后,因为加入了自动类型装换,因此,Java 虚拟机会在存储方面进行相应的优化。

8. 判断垃圾可以回收的方法有哪些?

  • 1、引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1; 当引用失效时,计数器值就减1; 任何时刻计数器为0的对象就是不可能再被使用的。 客观地说,引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法, 也有一些比较著名的应用案例。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存, 其中最主要的原因是它很难解决对象之间相互循环引用的问题。

  • 2、可达性分析算法

在主流的商用程序语言的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思路就是 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链, 当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 JNI(Native方法)引用的对象。

9. 垃圾回收是从哪里开始的呢?

查找哪些对象是正在被当前系统使用的。上面分析的堆和栈的区别,其中栈是真正进行程序执行地方,所以要获取哪些对象正在被使用,则需要从 Java 栈开始。同时,一个栈是与一个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。

同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以 null 引用或者基本类型结束,这样就形成了一颗以 Java 栈中引用所对应的对象为根节点的一颗对象树。如果栈中有多个引用,则最终会形成多颗对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。

10. 被标记为垃圾的对象一定会被回收吗?

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们 暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

(1)如果对象在进行可达性分析后,发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(即意味着直接回收)

(2)如果这个对象被判定为有必要执行finalize()方法,那么 这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。 这里所谓的“执行”是指虚拟机会触发这个方,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合; 如果对象这时候还没有逃脱,那基本上它就真的被回收了。

代码示例:

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes,i am still alive:)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        // 对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead:(");
        }
        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead:(");
        }
    }
}


// 结果  
finalize mehtod executed!
yes,i am still alive :)
no,i am dead :(

SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败。这是因为 任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。因为finalize()方法已经被虚拟机调用过,虚拟机都视为“没有必要执行”。(即意味着直接回收)

11. 谈谈对 Java 中引用的了解?

Java中存在四种引用机制,分别是强引用,软引用,弱引用,虚引用

强引用

我们用new方式创建的引用就是强引用。

Client client = new Client()

软引用

SoftReference实例来保存一个对象的软引用。在内存不够用时,会优先回收只有软引用的内存空间,主要是做缓存用。

SoftReference aSoftRef=new SoftReference(aRef);  

弱引用

只要是触发垃圾回收机制进行回收,只具有弱引用的对象就会被回收

  • tomcat中就是使用的弱应用
  • ThreadLocal也是使用弱应用 如:weakhashmap

虚引用

主要是管理堆外内存的,主要是给写jvm的使用
主要检测队列配合使用,虚引用API无法get到值,主要是通知对象已经被回收,去清理堆外的内存

图片对比

four

12. 谈谈对内存泄漏的理解?

堆内存溢出(OutOfMemoryError: Java heap space)

内存溢出是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。 会导致 JVM 内存溢出的一些场景:

  • JVM 启动参数堆内存值设定的过小
  • 内存中加载的数据量过于庞大(一次性从 Mysql、Redis 取出过多数据)
  • 对象的引用没有及时释放,使得JVM不能回收
  • 代码中存在死循环或循环产生过多重复的对象实体

方法区内存溢出(outOfMemoryError:permgem space)

加载的类过多,或者使用反射、gclib等这种动态代理生成类的技术,就可能导致该区发生内存溢出(1.8之前)

线程栈溢出(java.lang.StackOverflowError)

  • 线程栈时线程独有的一块内存结构,所以线程栈发生问题必定是某个线程运行时产生的错误
  • 线程栈溢出是由于递归太深或方法调用层级过多导致的

内存泄漏(Memory Leak)

内存泄漏是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

会导致 Java 内存泄漏的一些场景:

  • 长生命周期的对象持有短生命周期对象的引用
  • 过度使用静态成员属性(static fields)
  • 忘记关闭已打开的资源链接(unclosed Resources)
  • 没有正确的重写 equals 和 hashcode 方法。(HashMap HashSet)当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段,否则对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中删除当前对象,造成内存泄露。

开发时的编码建议:

  • 尽早释放无用对象的引用
  • 尽量少用静态变量,因为静态变量存放在永久代(方法区),永久代基本不参与垃圾回收(1.8之前)
  • 避免在循环中创建对象
  • 使用字符串处理,避免使用String,应大量使用StringBuffer,每一个String对象都得独立占用内存一块区域
  • 开启大型文件或从数据库一次拿了太多的数据很容易造成内存溢出,所以在这些地方要大概计算一下数据量的最大值是多少,并且设定所需最小及最大的内存空间值。

13. 内存泄露的根本原因是什么?

长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要, 但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。

举例

  1. 静态集合类引起的内存泄漏;
  2. 当集合里面的对象属性被修改后,再调用 remove() 方法时不起作用;
  3. 释放对象的时候没有删除
  4. 各种连接:比如数据库连接(dataSourse.getConnection()),网络连接(socket) 和 IO 连接,除非其显式的调用了其 close() 方法将其连接关闭,否则是不会自动被 GC 回收的;
  5. 内部类:内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放;
  6. 单例模式:单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏。

14. 尽量避免内存泄漏的方法?

  1. 尽量不要使用 static 成员变量,减少生命周期;
  2. 及时关闭资源;
  3. 不用的对象,可以手动设置为 null。

15. 为什么要采用分代收集算法?

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。
因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关, 比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩, 因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量, 这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象。

有些对象甚至只用一次即可回收。在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象。

但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历, 但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分, 把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

16 常用的垃圾收集算法有哪些?

https://www.yuque.com/lexiao-1kmgg/ah8dgx/pbmg9t#yRvuY

17. 分代收集下的年轻代和老年代应该采用什么样的垃圾回收算法?

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不同的垃圾收集算法。 一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

18. 什么是浮动垃圾?

由于在应用运行的同时进行垃圾回收,所以有些垃圾可能在垃圾回收进行完成时产生,这样就造成了“Floating Garbage”,这些垃圾需要在下次垃圾回收周期时才能回收掉。所以,并发收集器一般需要20%的预留空间用于这些浮动垃圾。

19. 什么是内存碎片?如何解决?

由于不同 Java 对象存活时间是不一定的,因此,在程序运行一段时间以后,如果不进行内存整理,就会出现零散的内存碎片。碎片最直接的问题就是会导致无法分配大块的内存空间,以及程序运行效率降低。所以,在上面提到的基本垃圾回收算法中,“复制”方式和“标记-整理”方式,都可以解决碎片的问题。

20. 常用的垃圾收集器有哪些?

https://www.bilibili.com/video/BV1N54y1m7uZ?spm_id_from=333.337.search-card.all.click

参考资料 收集器

G1

cms

21. 说下你对垃圾回收策略的理解/垃圾回收时机?

  1. Minor / Scavenge GC

所有对象创建在新生代的 Eden 区,当 Eden 区满后触发新生代的 Minor GC,将 Eden 区和非空闲 Survivor 区存活的对象复制到另外一个空闲的Survivor 区中。保证一个 Survivor 区是空的,新生代 Minor GC 就是在两个 Survivor 区之间相互复制存活对象,直到 Survivor 区满为止。

Minor/Scavenge 这种方式的 GC 是在年轻代的 Eden 区进行,不会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。

  1. Major GC

发生在老年代的GC ,基本上发生了一次Major GC 就会发生一次 Minor GC。并且Major GC 的速度往往会比 Minor GC 慢 10 倍。

什么时候会发生 Major GC 呢?

  1. 对于一个大对象,我们会首先在Eden 尝试创建,如果创建不了,就会触发Minor GC

  2. 随后继续尝试在Eden区存放,发现仍然放不下

  3. 尝试直接进入老年代,老年代也放不下

  4. 触发 Major GC 清理老年代的空间

  5. Full GC

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

如下原因可能导致 Full GC:

1、 调用 System.gc(),会建议虚拟机执行 Full GC。只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。

2、 老年代空间不足,原因:老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full

GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间;

3、 空间分配担保失败:使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC;

4、JDK 1.7 及以前的永久代空间不足。在 JDK1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5、Concurrent Mode Failure 执行 CMS GC 的过程中,同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

22. 谈谈你对内存分配的理解?大对象怎么分配?空间分配担保?

  1. 对象优先在 Eden 区分配:大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

  2. 大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor区之间的大量内存复制。

  3. 长期存活的对象将进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄的阈值。

  4. 动态对象年龄判定:为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。

  5. 空间分配担保

(1)在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;

(2)如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

23. 说下你用过的 JVM 监控工具?

名称 主要作用
jps 查看正在运行的Java进程
jstack 打印线程快照
jmap 导出堆内存映像文件
jstat 查看jvm统计信息
jinfo 实时查看和修改jvm配置参数
jhat 用于分析heapdump文件

24. 如何利用监控工具调优?

堆参数

-Xms: 堆的初始值,例如 -Xms2048,初始堆大小为 2G

-Xmx: 堆的最大值,例如 -Xmx2048M,允许最大堆内存 2G

-Xmn: 新生代大小

-XX:SurvivorRatio:Eden 区所占比例,默认是 8,也就是 80%,例如 -XX:SurvivorRatio=8

param

栈参数

-Xss:栈空间大小,栈是线程独占的,所以是一个线程使用栈空间的大小,例如 -Xss256K,如果不设置此参数,默认值是 1M,一般来讲设置成 256K 就足够了。

Metaspace 参数

-XX:MetaspaceSize:Metaspace 空间初始大小,如果不设置的话,默认是20.79M,这个初始大小是触发首次 Metaspace Full GC 的阈值,例如 -XX:MetaspaceSize=256M

-XX:MaxMetaspaceSize:Metaspace 最大值,默认不限制大小,但是线上环境建议设置,例如

-XX:MaxMetaspaceSize=256M

-XX:MinMetaspaceFreeRatio:最小空闲比,当 Metaspace 发生 GC 后,会计算 Metaspace 的空闲比,如果空闲比(空闲空间/当前 Metaspace 大小)小于此值,就会触发 Metaspace 扩容。默认值是 40 ,也就是 40%,例如 -XX:MinMetaspaceFreeRatio=40

-XX:MaxMetaspaceFreeRatio:最大空闲比,当 Metaspace 发生 GC 后,会计算 Metaspace 的空闲比,如果空闲比(空闲空间/当前 Metaspace 大小)大于此值,就会触发 Metaspace 释放空间。默认值是 70 ,也就是 70%,例如 -XX:MaxMetaspaceFreeRatio=70

建议将 MetaspaceSize 和 MaxMetaspaceSize 设置为同样大小,避免频繁扩容。

GC 日志

简单日志

-verbose:gc 或者 -XX:+PrintGC

日志格式:

[GC (Allocation Failure)  7892K->5646K(19456K), 0.0060442 secs]
[GC (Allocation Failure) , 0.0066315 secs]
[Full GC (Allocation Failure)  19302K->13646K(19456K), 0.0032698 secs]

详细日志

#打印详细日志
-XX:+PrintGCDetails
#打印 GC 的时间点
-XX:+PrintGCDateStamps

日志格式:

2019-11-13T14:06:46.099-0800: [GC (Allocation Failure) 2019-11-13T14:06:46.099-0800: [DefNew (promotion failed) : 9180K->9157K(9216K), 0.0084297 secs]2019-11-13T14:06:46.107-0800: [Tenured: 10145K->10145K(10240K), 0.0035768 secs] 13802K->13646K(19456K), [Metaspace: 3895K->3895K(1056768K)], 0.0120887 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
2019-11-13T14:06:47.243-0800: [Full GC (Allocation Failure) 2019-11-13T14:06:47.244-0800: [Tenured: 10145K->10145K(10240K), 0.0042686 secs] 19304K->19146K(19456K), [Metaspace: 3895K->3895K(1056768K)], 0.0043232 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

以下几个 GC 日志相关的参数打印的内容比较多,生产环境可选择性开启,大多数时候不需要开启。

GC 前后的堆信息

-XX:+PrintHeapAtGC

{Heap before GC invocations=0 (full 0):
 def new generation   total 9216K, used 7892K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  96% used [0x00000007bec00000, 0x00000007bf3b5200, 
  xxx....
  class space    used 445K, capacity 462K, committed 512K, reserved 1048576K
Heap after GC invocations=1 (full 0):
 def new generation   total 9216K, used 1023K [0x00000007bec00000,
 xxx...
 Metaspace       used 3892K, capacity 4646K, committed 4864K, reserved 1056768K
  class space    used 445K, capacity 462K, committed 512K, reserved 1048576K
}

GC 导致的 Stop the world 时间

-XX:+PrintGCApplicationStoppedTime

Total time for which application threads were stopped: 0.0070384 seconds, Stopping threads took: 0.0000200 seconds

加载类信息

-verbose:class

[Loaded java.net.URLClassLoader$3$1 from /Library/Java/JavaVirtualMachines/jdk1.8.0_111.jdk/Contents/Home/jre/lib/rt.jar]

GC 前后的类加载情况

-XX:+PrintClassHistogramBeforeFullGC
-XX:+PrintClassHistogramAfterFullGC
 num     #instances         #bytes  class name
----------------------------------------------
   1:           140       19016264  [B
   2:          2853         226256  [C
   3:           138         169072  [I
   4:           761          86240  java.lang.Class
   5:          2850          68400  java.lang.String
   6:           660          41024  [Ljava.lang.Object;

日志输出到文件

以上参数配置好之后,默认会输出到控制台或者服务指定的统一日志的位置。但是这里还会有服务的一般性信息日志、错误日志等,都混在一起的话会比较乱,所以,一般都会把 jvm 日志单独存放。

#GC 活动日志根据配置的参数输出内容
-Xloggc:/Users/fengzheng/jvmlog/gc.log

#致命错误日志只有在 jvm 发生崩溃的时候会输出
-XX:ErrorFile=/Users/fengzheng/jvmlog/hs_err_pid%p.log

堆溢出现场保留

有些错误虽然不会导致 jvm 崩溃,但是对于服务而言也是非常严重的,比如stackOverflow、OutOfMemoryError,发生错误后,留存现场信息对分析错误原因是至关重要的。jvm 提供了保留堆溢出现场的方法,对于 JDK 8 而言,可能是 heap 溢出,也可能是 Metasapce 溢出。

-XX:HeapDumpPath=/Users/fengzheng/jvmlog
-XX:+HeapDumpOnOutOfMemoryError

最后出现异常后,保存的文件格式为 java_pidxxx.hprof,pid 后面是发生溢出的进程 id,之后可以用 VisualVM、JProfiler 等工具打开分析。

25. 谈谈你对类文件结构的理解?有哪些部分组成?

26. 类加载各阶段的作用分别是什么?

27. 有哪些类加载器?分别有什么作用?

28. 类与类加载器的关系?

29. 谈谈你对双亲委派模型的理解?工作过程?为什么要使用?

https://www.yuque.com/lexiao-1kmgg/ah8dgx/uc65w0#eOpAw

30. 怎么实现一个自定义的类加载器?需要注意什么?

// TODO

31. 怎么打破双亲委派模型?

32. 有哪些实际场景是需要打破双亲委派模型的?

https://mp.weixin.qq.com/s?__biz=MzU4NzA3MTc5Mg==&mid=2247485521&idx=1&sn=b906b5e785e1821e52039b5620495e99&chksm=fdf0e00eca8769181d0508c349cbc18def10a6ec53fc3c59fbe4072aad39046227f32725df40&scene=178&cur_album_id=2137264927726764033#rd

33. 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?

解释器:程序可以迅速启动和执行,消耗内存小 (类似人工,成本低,到后期效率低);

编译器:随着代码频繁执行会将代码编译成本地机器码 (类似机器,成本高,到后期效率高)。

在整个虚拟机执行架构中,解释器与编译器经常配合工作,两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

解释执行可以节约内存,而编译执行可以提升效率。因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作。

34. 说下你对 Java 内存模型的理解?

MicroKibaco/CrazyDailyQuestion#8

6. SSM框架

1. 使用 Spring 框架的好处是什么?

  • 轻量: Spring 是轻量的,基本的版本大约2MB。
  • 控制反转: Spring通过控制反转实现了松散耦合,对象们给出它们的依赖,而不是创建或查找依赖的对象们。
  • 面向切面的编程(AOP): Spring支持面向切面的编程,并且把应用业务逻辑和系统服务分开。
  • 容器: Spring 包含并管理应用中对象的生命周期和配置。
  • MVC框架: Spring的WEB框架是个精心设计的框架,是Web框架的一个很好的替代品。
  • 事务管理: Spring 提供一个持续的事务管理接口,可以扩展到上至本地事务下至全局事务(JTA)。
  • 异常处理: Spring 提供方便的API把具体技术相关的异常(比如由JDBC,Hibernate or JDO抛出的)转化为一致的unchecked 异常。

2. 解释下什么是 AOP?

AOP(Aspect Orient Programming)是一种设计思想,是软件设计领域中的面向切面编程,它是面向对象编程(OOP)的一种补充和完善。它以通过预编译方式和运行期动态代理方式,实现在不修改源代码的情况下给程序动态统一添加额外功能的一种技术。如图-1所示:

aop

AOP与OOP字面意思相近,但其实两者完全是面向不同领域的设计思想。实际项目中我们通常将面向对象理解为一个静态过程(例如一个系统有多少个模块,一个模块有哪些对象,对象有哪些属性),面向切面的运行期代理方式,理解为一个动态过程,可以在对象运行时动态织入一些扩展功能或控制对象执行。

AOP 应用场景分析

实际项目中通常会将系统分为两大部分,一部分是核心业务,一部分是非核业务。在编程实现时我们首先要完成的是核心业务的实现,非核心业务一般是通过特定方式切入到系统中,这种特定方式一般就是借助AOP进行实现。

AOP就是要基于OCP(开闭原则),在不改变原有系统核心业务代码的基础上动态添加一些扩展功能并可以"控制"对象的执行。例如AOP应用于项目中的日志处理,事务处理,权限处理,缓存处理等等。如图-2所示:

aop

AOP 应用原理分析

  1. 假如目标对象(被代理对象)实现接口,则底层可以采用JDK动态代理机制为目标对象创建代理对象(目标类和代理类会实现共同接口)。
  2. 假如目标对象(被代理对象)没有实现接口,则底层可以采用CGLIB代理机制为目标对象创建代理对象(默认创建的代理类会继承目标对象类型)。 Spring AOP 原理分析,如图-3所示:

dynamic

AOP 相关术语分析

切面(aspect): 横切面对象,一般为一个具体类对象(可以借助@Aspect声明)。 通知(Advice):在切面的某个特定连接点上执行的动作(扩展功能),例如around,before,after等。 连接点(joinpoint):程序执行过程中某个特定的点,一般指被拦截到的的方法。 切入点(pointcut):对多个连接点(Joinpoint)一种定义,一般可以理解为多个连接点的集合。 连接点与切入点定义如图-4所示:

aop_concept

说明:我们可以简单的将机场的一个安检口理解为连接点,多个安检口为切入点,安全检查过程看成是通知。总之,概念很晦涩难懂,多做例子,做完就会清晰。先可以按白话去理解。

AOP实现步骤

公式:AOP=(切面)=通知方法(5种)+切入点表达式(4种)

  1. 通知方法
  • @before通知------->在执行目标方法之前执行
  • @after通知------->在执行目标方法之后执行
  • @afterReturning通知------->无论什么时候程序执行完成之后都要执行的通知
  • @afterThrowing通知------->在目标方法执行之后报错时执行 (上述四大类型通知,不能控制目标方法是否执行。一般用来记录程序的执行状态。一般应用与监控的操作。(不改变程序运行的轨迹)
  • @around通知-------> 在目标方法执行前后执行 (环绕通知可控制目标方法是否执行,控制程序的执行的轨迹
  1. 切入点表达式
  • @bean(“beanId”) -------> bean:交给spring容器管理的对象,粒度:粗粒度 按bean匹配 当前bean中的方法都会执行通知
  • @within(“包名.类名”) ------->粒度:粗粒度 可以匹配多个类
  • @execution("返回值类型 包名.类名.方法名(参数列表)-------> 细粒度:方法参数级别
  • @annotation(“包名.类名”) ------->细粒度:按照注解匹配
@Aspect
@Component//交给spring容器管理
public class CacheAOP {

    //@Pointcut("bean(itemCatServiceImpl)")
    //@Pointcut("within(com.jt.service.ItemCatServiceImpl)")
    //@Pointcut("within(com.jt.service.*)")//.*一级包目录,..*所有子孙后代
    @Pointcut("execution(* com.jt.service..*.add*(..))")
    public void pointCut(){

    }

    @Before("pointCut()")
    public void before(){
        System.out.println("我是before通知");
    }
}

3. AOP 的代理有哪几种方式?

AOP 思想的实现一般都是基于代理模式 ,在 Java 中一般采用 JDK 动态代理模式,但是我们都知道,JDK 动态代理模式只能代理接口而不能代理类。因此,Spring AOP 会按照下面两种情况进行切换,因为 Spring AOP 同时支持 CGLIB、ASPECTJ、JDK 动态代理。

  1. 如果目标对象的实现类实现了接口,Spring AOP 将会采用 JDK 动态代理来生成 AOP 代理类;

  2. 如果目标对象的实现类没有实现接口,Spring AOP 将会采用 CGLIB 来生成 AOP 代理类。不过这个选择过程对开发者完全透明、开发者也无需关心。

4. 怎么实现 JDK 动态代理?

看博客

5. 谈谈你对 IOC 的理解?

IoC是什么

Ioc—Inversion of Control,即“控制反转”,不是什么技术,而是一种设计思想。在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。如何理解好Ioc呢?理解好Ioc的关键是要明确“谁控制谁,控制什么,为何是反转(有反转就应该有正转了),哪些方面反转了”,那我们来深入分析一下:

  ●谁控制谁,控制什么:传统Java SE程序设计,我们直接在对象内部通过new进行创建对象,是程序主动去创建依赖对象;而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制对 象的创建;谁控制谁?当然是IoC 容器控制了对象;控制什么?那就是主要控制了外部资源获取(不只是对象包括比如文件等)。

  ●为何是反转,哪些方面反转了:有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;为何是反转?因为由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,所以是反转;哪些方面反转了?依赖对象的获取被反转了。

  用图例说明一下,传统程序设计如图2-1,都是主动去创建相关对象然后再组合起来: ioc

IoC能做什么

  IoC 不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是 松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

  其实IoC对编程带来的最大改变不是从代码上,而是从思想上,发生了“主从换位”的变化。应用程序原本是老大,要获取什么资源都是主动出击,但是在IoC/DI思想中,应用程序就变成被动的了,被动的等待IoC容器来创建并注入它所需要的资源了。

  IoC很好的体现了面向对象设计法则之一—— 好莱坞法则:“别找我们,我们找你”;即由IoC容器帮对象找相应的依赖对象并注入,而不是由对象主动去找。

IoC和DI

DI—Dependency Injection,即“依赖注入”:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。

  理解DI的关键是:“谁依赖谁,为什么需要依赖,谁注入谁,注入了什么”,那我们来深入分析一下:

  ●谁依赖于谁:当然是应用程序依赖于IoC容器;

  ●为什么需要依赖:应用程序需要IoC容器来提供对象需要的外部资源;

  ●谁注入谁:很明显是IoC容器注入应用程序某个对象,应用程序依赖的对象;

  ●注入了什么:就是注入某个对象所需要的外部资源(包括对象、资源、常量数据)。

  IoC和DI由什么关系呢?其实它们是同一个概念的不同角度描述,由于控制反转概念比较含糊(可能只是理解为容器控制对象这一个层面,很难让人想到谁来维护对象关系),所以2004年大师级人物Martin Fowler又给出了一个新的名字:“依赖注入”,相对IoC 而言,“依赖注入”明确描述了“被注入对象依赖IoC容器配置依赖对象”。

6. Bean 的生命周期?

生命周期的概要流程

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

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

beanlife

  1. 实例化:第 1 步,实例化一个 bean 对象;
  2. 属性赋值:第 2 步,为 bean 设置相关属性和依赖;
  3. 初始化:第 3~7 步,步骤较多,其中第 5、6 步为初始化操作,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,该阶段结束,才能被用户使用;
  4. 销毁:第 8~10步,第8步不是真正意义上的销毁(还没使用呢),而是先在使用前注册了销毁的相关调用接口,为了后面第9、10步真正销毁 bean 时再执行相应的方法。

下面我们结合代码来直观的看下,在 doCreateBean() 方法中能看到依次执行了这 4 个阶段:

// AbstractAutowireCapableBeanFactory.java
protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
    throws BeanCreationException {

    // 1. 实例化
    BeanWrapper instanceWrapper = null;
    if (instanceWrapper == null) {
        instanceWrapper = createBeanInstance(beanName, mbd, args);
    }
    
    Object exposedObject = bean;
    try {
        // 2. 属性赋值
        populateBean(beanName, mbd, instanceWrapper);
        // 3. 初始化
        exposedObject = initializeBean(beanName, exposedObject, mbd);
    }

    // 4. 销毁-注册回调接口
    try {
        registerDisposableBeanIfNecessary(beanName, bean, mbd);
    }

    return exposedObject;
}

由于初始化包含了第 3~7步,较复杂,所以我们进到 initializeBean() 方法里具体看下其过程(注释的序号对应图中序号):

// AbstractAutowireCapableBeanFactory.java
protected Object initializeBean(final String beanName, final Object bean, @Nullable RootBeanDefinition mbd) {
    // 3. 检查 Aware 相关接口并设置相关依赖
    if (System.getSecurityManager() != null) {
        AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
            invokeAwareMethods(beanName, bean);
            return null;
        }, getAccessControlContext());
    }
    else {
        invokeAwareMethods(beanName, bean);
    }

    // 4. BeanPostProcessor 前置处理
    Object wrappedBean = bean;
    if (mbd == null || !mbd.isSynthetic()) {
        wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
    }

    // 5. 若实现 InitializingBean 接口,调用 afterPropertiesSet() 方法
    // 6. 若配置自定义的 init-method方法,则执行
    try {
        invokeInitMethods(beanName, wrappedBean, mbd);
    }
    catch (Throwable ex) {
        throw new BeanCreationException(
            (mbd != null ? mbd.getResourceDescription() : null),
            beanName, "Invocation of init method failed", ex);
    }
    // 7. BeanPostProceesor 后置处理
    if (mbd == null || !mbd.isSynthetic()) {
        wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
    }

    return wrappedBean;
}

在 invokInitMethods() 方法中会检查 InitializingBean 接口和 init-method 方法,销毁的过程也与其类似:

// DisposableBeanAdapter.java
public void destroy() {
    // 9. 若实现 DisposableBean 接口,则执行 destory()方法
    if (this.invokeDisposableBean) {
        try {
            if (System.getSecurityManager() != null) {
                AccessController.doPrivileged((PrivilegedExceptionAction<Object>) () -> {
                    ((DisposableBean) this.bean).destroy();
                    return null;
                }, this.acc);
            }
            else {
                ((DisposableBean) this.bean).destroy();
            }
        }
    }
    
	// 10. 若配置自定义的 detory-method 方法,则执行
    if (this.destroyMethod != null) {
        invokeCustomDestroyMethod(this.destroyMethod);
    }
    else if (this.destroyMethodName != null) {
        Method methodToInvoke = determineDestroyMethod(this.destroyMethodName);
        if (methodToInvoke != null) {
            invokeCustomDestroyMethod(ClassUtils.getInterfaceMethodIfPossible(methodToInvoke));
        }
    }
}

从 Spring 的源码我们可以直观的看到其执行过程,而我们记忆其过程便可以从这 4 个阶段出发,实例化、属性赋值、初始化、销毁。其中细节较多的便是初始化,涉及了 Aware、BeanPostProcessor、InitializingBean、init-method 的概念。这些都是 Spring 提供的扩展点,其具体作用将在下一节讲述。

扩展点的作用

  1. Aware 接口

若 Spring 检测到 bean 实现了 Aware 接口,则会为其注入相应的依赖。所以通过让bean 实现 Aware 接口,则能在 bean 中获得相应的 Spring 容器资源。 Spring 中提供的 Aware 接口有:

  • BeanNameAware:注入当前 bean 对应 beanName;
  • BeanClassLoaderAware:注入加载当前 bean 的 ClassLoader;
  • BeanFactoryAware:注入 当前BeanFactory容器 的引用。

其代码实现如下:

// AbstractAutowireCapableBeanFactory.java
private void invokeAwareMethods(final String beanName, final Object bean) {
    if (bean instanceof Aware) {
        if (bean instanceof BeanNameAware) {
            ((BeanNameAware) bean).setBeanName(beanName);
        }
        if (bean instanceof BeanClassLoaderAware) {
            ((BeanClassLoaderAware) bean).setBeanClassLoader(bcl);
            
        }
        if (bean instanceof BeanFactoryAware) {
            ((BeanFactoryAware) bean).setBeanFactory(AbstractAutowireCapableBeanFactory.this);
        }
    }
}

以上是针对 BeanFactory 类型的容器,而对于 ApplicationContext 类型的容器,也提供了 Aware 接口,只不过这些 Aware 接口的注入实现,是通过 BeanPostProcessor 的方式注入的,但其作用仍是注入依赖。

  • EnvironmentAware:注入 Enviroment,一般用于获取配置属性;
  • EmbeddedValueResolverAware:注入 EmbeddedValueResolver(Spring EL解析器),一般用于参数解析;
  • ApplicationContextAware(ResourceLoader、ApplicationEventPublisherAware、MessageSourceAware):注入 ApplicationContext 容器本身。

其代码实现如下:

// ApplicationContextAwareProcessor.java
private void invokeAwareInterfaces(Object bean) {
    if (bean instanceof EnvironmentAware) {
        ((EnvironmentAware)bean).setEnvironment(this.applicationContext.getEnvironment());
    }

    if (bean instanceof EmbeddedValueResolverAware) {
        ((EmbeddedValueResolverAware)bean).setEmbeddedValueResolver(this.embeddedValueResolver);
    }

    if (bean instanceof ResourceLoaderAware) {
        ((ResourceLoaderAware)bean).setResourceLoader(this.applicationContext);
    }

    if (bean instanceof ApplicationEventPublisherAware) {
        ((ApplicationEventPublisherAware)bean).setApplicationEventPublisher(this.applicationContext);
    }

    if (bean instanceof MessageSourceAware) {
        ((MessageSourceAware)bean).setMessageSource(this.applicationContext);
    }

    if (bean instanceof ApplicationContextAware) {
        ((ApplicationContextAware)bean).setApplicationContext(this.applicationContext);
    }
}
  1. BeanPostProcessor

BeanPostProcessor 是 Spring 为修改 bean提供的强大扩展点,其可作用于容器中所有 bean,其定义如下:

public interface BeanPostProcessor {

	// 初始化前置处理
	default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
		return bean;
	}

	// 初始化后置处理
	default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
		return bean;
	}

}

常用场景有:

  • 对于标记接口的实现类,进行自定义处理。例如1节中所说的ApplicationContextAwareProcessor,为其注入相应依赖;再举个例子,自定义对实现解密接口的类,将对其属性进行解密处理;
  • 为当前对象提供代理实现。例如 Spring AOP 功能,生成对象的代理类,然后返回。
// AbstractAutoProxyCreator.java
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
    TargetSource targetSource = getCustomTargetSource(beanClass, beanName);
    if (targetSource != null) {
        if (StringUtils.hasLength(beanName)) {
            this.targetSourcedBeans.add(beanName);
        }
        Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(beanClass, beanName, targetSource);
        Object proxy = createProxy(beanClass, beanName, specificInterceptors, targetSource);
        this.proxyTypes.put(cacheKey, proxy.getClass());
        // 返回代理类
        return proxy;
    }

    return null;
}
  1. InitializingBean 和 init-method

InitializingBean 和 init-method 是 Spring 为 bean 初始化提供的扩展点。
InitializingBean接口 的定义如下:

public interface InitializingBean {
	void afterPropertiesSet() throws Exception;
}

在 afterPropertiesSet() 方法写初始化逻辑。

指定 init-method 方法,指定初始化方法:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="demo" class="com.chaycao.Demo" init-method="init()"/>
    
</beans>

DisposableBean 和 destory-method 与上述类似,就不描述了。

7. Bean 的作用域?

一般情况下,我们书写在IOC容器中的配置信息,会在我们的IOC容器运行时被创建,这就导致我们通过IOC容器获取到bean对象的时候,往往都是获取到了单实例的Bean对象,

这样就意味着无论我们使用多少个getBean()方法,获取到的同一个JavaBean都是同一个对象,这就是单实例Bean,整个项目都会共享这一个bean对象。

在Spring中,可以在bean元素的scope属性里设置bean的作用域,以决定这个bean是单实例的还是多实例的。Scope属性有四个参数,具体的使用可以看下图:

scope

1. 单实例Bean声明

默认情况下,Spring只为每个在IOC容器里声明的bean创建唯一一个实例,整个IOC容器范围内都能共享该实例:所有后续的getBean()调用和bean引用都将返回这个唯一的bean实例。该作用域被称为singleton,它是所有bean的默认作用域。也就是单实例。

为了验证这一说法,我们在IOC中创建一个单实例的bean,并且获取该bean对象进行对比:

<!-- singleton单实例bean
  1、在容器创建时被创建
  2、只有一个实例
  Springtboot 的话,用scope注解来指定
  -->
<bean id="book02" class="com.spring.beans.Book" scope="singleton"></bean>

测试获取到的单实例bean是否是同一个:

@Test
public void test09() {
    // 单实例创建时创建的两个bean相等
    Book book03 = (Book)iocContext3.getBean("book02");
    Book book04 = (Book)iocContext3.getBean("book02");
    System.out.println(book03==book04); // true
}

2. 多实例Bean声明

而既然存在单实例,那么就一定存在多实例。我们可以为bean对象的scope属性设置prototype参数,以表示该实例是多实例的,同时获取IOC容器中的多实例bean,再将获取到的多实例bean进行对比,

<!-- prototype多实例bean
1、在容器创建时不会被创建,
2、只有在被调用的时候才会被创建
3、可以存在多个实例
 -->
<bean id="book01" class="com.spring.beans.Book" scope="prototype"></bean>
@Test
public void test09() {
    // 多实例创建时,创建的两个bean对象不相等
    Book book01 = (Book)iocContext3.getBean("book01");
    Book book02 = (Book)iocContext3.getBean("book01");
    System.out.println(book01==book02); // false
}

这就说明了,通过多实例创建的bean对象是各不相同的。

在这里需要注意: 同时关于单实例和多实例bean的创建也有不同,当bean的作用域为单例时,Spring会在IOC容器对象创建时就创建bean的对象实例。而当bean的作用域为prototype时,IOC容器在获取bean的实例时创建bean的实例对象。

8. Spring 中的单例 Bean 的线程安全问题了解吗?

Spring Bean作用域

Spring 的 bean 作用域(scope)类型有5种:

1、singleton:单例,默认作用域。
2、prototype:原型,每次创建一个新对象。
3、request:请求,每次Http请求创建一个新对象,适用于WebApplicationContext环境下。
4、session:会话,同一个会话共享一个实例,不同会话使用不用的实例。
5、global-session:全局会话,所有会话共享一个实例。

线程安全这个问题,要从单例与原型Bean分别进行说明。

「原型Bean」对于原型Bean,每次创建一个新对象,也就是线程之间并不存在Bean共享,自然是不会有线程安全的问题。

「单例Bean」对于单例Bean,所有线程都共享一个单例实例Bean,因此是存在资源的竞争。

如果单例Bean,是一个无状态Bean,也就是线程中的操作不会对Bean的成员执行「查询」以外的操作,那么这个单例Bean是线程安全的。比如Spring mvc 的 Controller、Service、Dao等,这些Bean大多是无状态的,只关注于方法本身。

spring单例,为什么controller、service和dao确能保证线程安全?

Spring中的Bean默认是单例模式的,框架并没有对bean进行多线程的封装处理。实际上大部分时间Bean是无状态的(比如Dao) 所以说在某种程度上来说Bean其实是安全的。

但是如果Bean是有状态的 那就需要开发人员自己来进行线程安全的保证,最简单的办法就是改变bean的作用域 把 singleton 改为 protopyte, 这样每次请求Bean就相当于是 new Bean() 这样就可以保证线程的安全了。

有状态就是有数据存储功能 无状态就是不会保存数据

controller、service和dao层本身并不是线程安全的,只是如果只是调用里面的方法,而且多线程调用一个实例的方法,会在内存中复制变量,这是自己的线程的工作内存,是安全的。

9. 谈谈你对 Spring 中的事物的理解?

概念

事务是在数据库开发中,一组业务逻辑操作,要么全部成功,要么全部失败。
原子性(atomicity)
一致性(consistency)
隔离性(isolation)
持久性(durability)

Spring有两种事务处理方式,一种是声明式事务,另外一种是编程式事务。

  • 声明式事务:底层建立在IoC和AOP的基础上,在方法的前后进行拦截,在方法执行前创建或者加入一个事务,方法执行后提交或者回滚事务。
  • 编程式事务:在方法的前后手动的开启和关闭事务,控制的粒度比声明式事务要小,可以达到代码块级别,但代码会很臃肿和复杂,项目中一般不会用。

声明式事务

在配置声明式事务管理的时候,我们用到最多的是注解方式,我们看下@Transactional这个注解的源码:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
	//定义事务管理器,这里的value是IoC容器中的Bean id
    @AliasFor("transactionManager")
    String value() default "";
    @AliasFor("value")
    String transactionManager() default "";
    //传播行为
    Propagation propagation() default Propagation.REQUIRED;
    //隔离级别
    Isolation isolation() default Isolation.DEFAULT;
    //超时时间
    int timeout() default -1;
    //是否开启只读事务
    boolean readOnly() default false;
    //回滚事务的异常类定义
    Class<? extends Throwable>[] rollbackFor() default {};
    //回滚事务的异常类名称定义
    String[] rollbackForClassName() default {};
    //当产生哪些异常不回滚
    Class<? extends Throwable>[] noRollbackFor() default {};
    //当产生哪些异常不回滚类名称定义
    String[] noRollbackForClassName() default {};
}

上面的属性中,这里重点解释传播行为和隔离级别

传播行为

下面是定义传播行为Propagation枚举类的源码:

public enum Propagation {
	//支持当前事务(如果当前存在事务,则加入到当前事务,总共一个事务),如果不存在 就新建一个(默认)
    REQUIRED(0),
    //支持当前事务,如果不存在,就不使用事务
    SUPPORTS(1),
    //支持当前事务,如果不存在,抛出异常
    MANDATORY(2),
    //如果有事务存在,挂起当前事务,新建一个新的事务(新建的事务是独立,与当前事务无关)
    REQUIRES_NEW(3),
    //以非事务方式运行,如果有事务存在,挂起当前事务
    NOT_SUPPORTED(4),
    //以非事务方式运行,如果有事务存在,抛出异常
    NEVER(5),
    //如果当前事务存在,则嵌套事务执行,作为当前事务中的一个子事务(依赖外层的事务,外层事务失败,子事务也要回滚)
    NESTED(6);
    private final int value;
    private Propagation(int value) {
        this.value = value;
    }
    public int value() {
        return this.value;
    }
}

隔离级别

先看下几个基本的概念:

  • 脏读:一个事务读取到另一事务未提交的更新数据。
  • 不可重复读:在同一事务中, 第一次读取和第二次读取过程中,有另外一个事务更新提交了数据,导致多次读取同一数据返回的结果不同。(update)
  • 幻读 : 第一个事务正在查询符合某一条件的数据,这时,另一个事务又插入了一条符合条件的数据,第一个事务在第二次查询符合同一条件的数据时,发现多了一条前一次查询时没有的数据,仿佛幻觉一样,这就是幻像读。(insert)

【提示】:不可重复读针对的是更新和删除操作导致数据不一致,幻读针对的是插入操作导致的数据不一致。

下面是定义隔离级别Isolation枚举类的源码:

public enum Isolation {
	//Spring默认的隔离级别,使用数据库默认的事务隔离级别
	//MYSQL: 默认为REPEATABLE_READ级别
	//SQLSERVER: 默认为READ_COMMITTED
    DEFAULT(-1),
    //读取未提交数据(会出现脏读, 不可重复读) 基本不使用
    READ_UNCOMMITTED(1),
    //读取已提交数据(会出现不可重复读和幻读)
    READ_COMMITTED(2),
    //可重复读(会出现幻读)
    REPEATABLE_READ(4),
    //串行化(防止脏读,不可重复读外,还避免了幻像读,但花费代价很大)
    SERIALIZABLE(8);
    private final int value;
    private Isolation(int value) {
        this.value = value;
    }
    public int value() {
        return this.value;
    }
}

10. Spring 常用的注入方式有哪些?

1. Field注入

field即在变量上直接使用注解进行注入,内部使用反射的方式实现注入到field中。

  • 优点:代码简洁有效,最为开发人员喜欢使用
  • 缺点:依赖对象可能为null而报空指针异常,容易出现循环依赖问题
 @Autowired
 private GoodsCategoryService goodsCategoryService;

2. setter注入

spring 3.x版本中推荐使用的注入方式,通过在set方法上使用注解完成对象注入。

  • 优点:选择性注入,可有可无,依赖不会影响整个项目运行
  • 缺点:在对象实例化后,依赖信息依然可以通过set方法修改,不太好
 private GoodsCategoryService goodsCategoryService;
 @Autowired
 public setGoodsCategoryService(GoodsCategoryService goodsCategoryService){
     this.goodsCategoryService = goodsCategoryService;
 }    

3. 构造器注入

spring 4.x版本时Spring团队推荐使用的注入方式,将注解标注在构造函数上,对象以参数的形式传递,在构造函数中完成注入对象的初始化。

  • 优点:变量定义使用强制性的显式注入(final关键字保证不可变)、避免空指针和循环依赖,
  • 缺点:在需要注入的依赖较多时,使用构造方法臃肿
private final GoodsCategoryService goodsCategoryService;
@Autowired
public GoodServiceImpl(GoodsCategoryService goodsCategoryService){
    this.goodsCategoryService = goodsCategoryService;
}

Spring为什么推荐使用构造器注入

Field注入尽管代码上简洁有效,但是会带来一些问题:

  • 对于IOC容器以外的环境,除了使用反射来提供它需要的依赖之外,无法复用该实现类
  • 不调用依赖bean时,不会发现空指针的异常,在运行时调用会报异常
  • 使用field注入可能会导致循环依赖,即A里面注入B,B里面又注入A
  • 会造成依赖臃肿,职责过多,使用Filed注入时,添加数量不受限制(没有警告),可能会注入过多的依赖项,违反了单一职责原则。

Setter方法注入是Spring 3.x版本推荐的注入方式,因为setter的方式能用让类在之后重新配置或者重新注入,但是

  • 写起来比较麻烦,每个依赖都需要一个set方法
  • 可以重新配置或注入虽然方便,但是有些依赖需要不可变,使用此种方式产生不确定性

构造器注入是Spring 4.x 版本推荐的注入方式,主要优点有:

  • 依赖不可变,使用final定义,保证注入后的不可变
  • 依赖不为null,使用构造函数传参的方式,在实例化对象传参时如果参数为null,则报错,提前避免了空指针异常
  • 在传参时传入的是依赖对象,而该对象传入时会保证对象类已经完成了初始化,也同时保证了对象不为null
  • 如果构造器注入时发生了循环依赖,在项目启动时就会报错BeanCurrentlyInCreationException,而Field注入只有在使用时才会报错

@Autowired、@Resource和@Inject的区别

  • @Autowired

    • @Autowired注解是Spring2.5之后带入的注解,通过AutowiredAnnotationBeanPostProcessor类实现注入
    • @Autowired可以标注在CONSTRUCTOR、METHOD、PARAMETER、FIELD、ANNOTATION_TYPE之上
    • @Autowired默认根据bean类型进行自动装配,byType
    • @Autowired注入时如果发现同类型有多个bean,此时需要使用@Qualifier注解来指定bean的name,即byName
    • @Autowired注解有属性required,默认为true,设置为false则表示未找到对应bean时不抛出异常
  • @Resource

    • @Resource时javax.annotation包下的注解
    • @Resource可以标注在TYPE, FIELD, METHOD之上
    • @Resource是根据属性名称进行自动装配的,其拥有属性name,可以根据属性值指定装配bean的name
  • @Inject

    • @Inject注解需要导入javax.inject.Inject包,能实现注入 @Inject可以标注在CONSTRUCTOR、METHOD、FIELD之上 @Inject注解是根据类型自动装配的,如果需要指定名称,则需要配合@Named注解

11. Spring 框架中用到了哪些设计模式?

看博客

12. ApplicationContext 通常的实现有哪些?

  1. FileSystemXmlApplicationContext:此容器从一个XML文件中加载bean的定义,XML Bean配置文件的全路径名必须提供给它的构造函数

  2. ClassPathXmlApplicationContext:此容器也从一个XML文件中加载bean的定义,这里需要正确设置classpath因为这个容器将在classpath里找bean配置

  3. WebXmlApplicationContext:此容器加载一个XML文件,此文件定义了一个WEB应用的所有bean

13. 谈谈你对 MVC 模式的理解?

MVC是Model—View—Controler的简称。即模型—视图—控制器。MVC是一种设计模式,它强制性的把应用程序的输入、处理和输出分开。

MVC中的模型、视图、控制器它们分别担负着不同的任务。 

视图: 视图是用户看到并与之交互的界面。视图向用户显示相关的数据,并接受用户的输入。视图不进行任何业务逻辑处理。 

模型: 模型表示业务数据和业务处理。相当于JavaBean。一个模型能为多个视图提供数据。这提高了应用程序的重用性 

控制器: 当用户单击Web页面中的提交按钮时,控制器接受请求并调用相应的模型去处理请求。             

然后根据处理的结果调用相应的视图来显示处理的结果。

MVC的处理过程:首先控制器接受用户的请求,调用相应的模型来进行业务处理,并返回数据给控制器。控制器调用相应的视图来显示处理的结果。并通过视图呈现给用户。

14. SpringMVC 的工作原理/执行流程?

1.前端控制器(DispatcherServlet)

本质上是一个Servlet,相当于一个中转站,所有的访问都会走到这个Servlet中,再根据配置进行中转到相应的Handler中进行处理,获取到数据和视图后,在使用相应视图做出响应。

2. 处理器映射器(HandlerMapping)

本质上就是一段映射关系,将访问路径和对应的Handler存储为映射关系,在需要时供前端控制器查阅。

3. 处理器适配器(HandlerAdapter)

本质上是一个适配器,可以根据要求找到对应的Handler来运行。前端控制器通过处理器映射器找到对应的Handler信息之后,将请求响应和对应的Handler信息交由处理器适配器处理,处理器适配器找到真正handler执行后,将结果即model和view返回给前端控制器 

4. 视图解析器(ViewResolver)

本质上也是一种映射关系,可以将视图名称映射到真正的视图地址。前端控制器调用处理器适配完成后得到model和view,将view信息传给视图解析器得到真正的view。

5. 视图渲染(View)

本质上就是将handler处理器中返回的model数据嵌入到视图解析器解析后得到的jsp页面中,向客户端做出响应。

springmvc_flow

15. SpringMVC 常用的注解

  1. @Controller
    @Controller 用于标记在一个类上,使用它标记的类就是一个SpringMVC Controller 对象。分发处理器将会扫描使用了该注解的类的方法,并检测该方法是否使用了@RequestMapping 注解。@Controller 只是定义了一个控制器类,而使用@RequestMapping 注解的方法才是真正处理请求的处理器。

  2. @RequsestMapping
    RequestMapping是一个用来处理请求地址映射的注解,可用于类或方法上。用于类上,表示类中的所有响应请求的方法都是以该地址作为父路径。
    返回值会通过视图解析器解析为实际的物理视图,对于 InternalResourceViewResolver 视图解析器,会做如下的解析:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix" value="/WEB-INF/views/"></property>
    <property name="suffix" value=".jsp"></property>
</bean>

通过 prefix + returnVal + suffix 这样的方式得到实际的物理视图,然后做转发操作;

RequsestMapping有六个属性‘

1、 value

    value:指定请求的实际地址;

2、method;

    method: 指定请求的method类型, GET、POST、PUT、DELETE等

3、consumes

    consumes: 指定处理请求的提交内容类型(Content-Type),例如application/json, text/html;

4、produces

    produces: 指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回;

5、params

    params: 指定request中必须包含某些参数值是,才让该方法处理。

6、headers

    headers: 指定request中必须包含某些指定的header值,才能让该方法处理请求。

  1. @ResponseBody
    把Java对象转化为json对象,用于ajax处,返回的不是一个页面而是一个某种格式的数据

  2. @valid 标识校验该数据

  3. @PathVariable 接收uri地址传过来的参数

  4. SessionAttribute(names=("","","",....))
    把转发数据的作用于改为Session

  5. @RequestParam
    @RequestParam用于将请求参数区数据映射到功能处理方法的参数上,用例:
    不加@RequestParam写法参数为非必传,加上参数为必传。参数名和传过来的参数名相同。
    加上@RequestParam可以通过@RequestParam(required = false)设置为非必传。因为required值默认是true,所以默认必传。
    @RequestParam可以通过@RequestParam("userId")或者@RequestParam(value = "userId")指定参数名。
    @RequestParam可以通过@RequestParam(defaultValue = "0")指定参数默认值

  6. @ExceptionAdvice
    标识一个异常处理类

  7. @ExceptionHandler
    标注一个方法为异常处理的方法

  8. @InitBinder
    处理时间参数

16. SpringMVC 的控制器是不是单例模式,如果是会有什么问题,怎么解决?

默认情况下是单例模式, 在多线程进行访问的时候,有线程安全问题. 但是不建议使用同步,因为会影响性能.

解决方案,是在控制器里面不能写成员变量.

为什么设计成单例设计模式?

  • 性能(不用每次请求都创建对象)
  • 不需要多例(不要在控制器中定义成员变量)

17. SpringMVC 怎么样设定重定向和转发的?

  • forward请求转发
// 控制器代码:
 @RequestMapping("test4")
    public String test4(){
        System.out.println("我是请求转发");
        return "forward:/response/test1";
    }
  • redirect重定向
// 控制器代码:
  @RequestMapping("test5")
    public String test5(RedirectAttributes redirectAttributes,String pname){
        System.out.println("我是重定向");
       // redirectAttributes.addAttribute("pname",pname);
        redirectAttributes.addFlashAttribute("pname",pname);
        return "redirect:/response/test1";
    }

//注意:重定向携带参数,需要使用对象RedirectAttributes,该对象提供两个方法封装参数
//addAttribute()和addFlashAttribute(),第一个方法参数会明文显示在浏览器地址栏,
//第二个方法参会会隐藏,使用第二种方法传参时,获取参数时需要加注解@ModelAttribute;

18. SpringMVC 里面拦截器是怎么写的?

SpringMVC 中的Interceptor 拦截请求是通过HandlerInterceptor 来实现的。在SpringMVC 中定义一个Interceptor 非常简单,主要有两种方式:

第一种方式是要定义的Interceptor类要实现了Spring 的HandlerInterceptor 接口,或者是这个类继承实现了HandlerInterceptor 接口的类,比如Spring 已经提供的实现了HandlerInterceptor 接口的抽象类HandlerInterceptorAdapter ;

第二种方式是实现Spring的WebRequestInterceptor接口,或者是继承实现了WebRequestInterceptor的类。

19. 谈谈你对 MyBatis 的理解?

1、Mybatis是一个半ORM(对象关系映射)框架,它内部封装了JDBC,开发时只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程。程序员直接编写原生态sql,可以严格控制sql执行性能,灵活度高。

2、MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。

3、通过xml 文件或注解的方式将要执行的各种 statement 配置起来,并通过java对象和 statement中sql的动态参数进行映射生成最终执行的sql语句,最后由mybatis框架执行sql并将结果映射为java对象并返回。(从执行sql到返回result的过程)。

20. MyBaits 的优缺点有哪些?

  • pros

1、基于SQL语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。

2、与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接;

3、很好的与各种数据库兼容(因为MyBatis使用JDBC来连接数据库,所以只要JDBC支持的数据库MyBatis都支持)。

4、能够与Spring很好的集成;

5、提供映射标签,支持对象与数据库的ORM字段关系映射;提供对象关系映射标签,支持对象关系组件维护。

  • cons

1、SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。

2、SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

21. MyBatis 与 Hibernate 有哪些不同?

1、Mybatis和hibernate不同,它不完全是一个ORM框架,因为MyBatis需要程序员自己编写Sql语句。

2、Mybatis直接编写原生态sql,可以严格控制sql执行性能,灵活度高,非常适合对关系数据模型要求不高的软件开发,因为这类软件需求变化频繁,一但需求变化要求迅速输出成果。但是灵活的前提是mybatis无法做到数据库无关性,如果需要实现支持多种数据库的软件,则需要自定义多套sql映射文件,工作量大。

3、Hibernate对象/关系映射能力强,数据库无关性好,对于关系模型要求高的软件,如果用hibernate开发可以节省很多代码,提高效率。

22. MyBatis 中 #{} 和 ${}的区别是什么?

#{}是预编译处理,${}是字符串替换。

Mybatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值;

Mybatis在处理${}时,就是把${}替换成变量的值。

使用#{}可以有效的防止SQL注入,提高系统安全性。

23. MyBatis 是如何进行分页的?分页插件的原理是什么?

Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页。可以在sql内直接书写带有物理分页的参数来完成物理分页功能,也可以使用分页插件来完成物理分页。

分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,根据dialect方言,添加对应的物理分页语句和物理分页参数。

24. Mybatis的一级、二级缓存

1)一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当 Session flush 或 close 之后,该 Session 中的所有 Cache 就将清空,默认打开一级缓存。

2)二级缓存与一级缓存其机制相同,默认也是采用 PerpetualCache,HashMap 存储,不同在于其存储作用域为 Mapper(Namespace),并且可自定义存储源,如 Ehcache。默认不打开二级缓存,要开启二级缓存,使用二级缓存属性类需要实现Serializable序列化接口(可用来保存对象的状态),可在它的映射文件中配置 ;

3)对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了C/U/D 操作后,默认该作用域下所有 select 中的缓存将被 clear。

25. Mybatis动态sql有什么用?执行原理?有哪些动态sql?

Mybatis动态sql可以在Xml映射文件内,以标签的形式编写动态sql,执行原理是根据表达式的值 完成逻辑判断并动态拼接sql的功能。

Mybatis提供了9种动态sql标签:trim where set foreach if choose when otherwise bind。

7 Mysql

也可以参考此博客

1. 请说下你对 MySQL 架构的了解?

mysql架构图

mysql_archetecture

再来看看我们开发的系统架构图:

web_archetecture

其实还是蛮相似的,都有分层的概念。既然我们开发的软件系统能进行分层,那么MySQL能分层吗?

答案是:能,下面我们就来聊聊MySQL的分层情况以及每一层的功能。

架构图分层

上面的架构图我们可以对其进行拆分,并做简要的说明。

  • 连接层
    connection
    与客户端打交道,上面已经写明了能支持的的语言。客户端的链接支持的协议很多,比如我们在 Java 开发中的 JDBC。
    这一层是不是有点像我们项目中的网关层?如果对网关不熟悉,那我们可以理解我controller层。

  • 服务层
    service
    这一层,就相当于我们业务系统中的service层,大杂烩,相关业务的操作、代码优化、缓存等都在这里面。

  • 连接池
    主要是负责存储和管理客户端与数据库的链接,一个线程负责管理一个连接。自从引入了连接池以后,官方报道:当数据库的连接数达到128后,使用连接池与没有连接池的性能是提升了n倍(反正就是性能大大的提升了)。 连接建立完成后,就可以执行select语句了。执行逻辑就会先来到缓存模块。

  • 缓存

MySQL拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果会以key-value对的形式存储在内存中。key是查询的语句,value是查询的结果。如果你的查询能够直接在这个缓存中找到key(命中),那么这个value就会被直接返回给客户端。

如果在缓存中未命中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。这里可以看到,如果查询命中缓存,MySQL不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。
但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。

查询缓存的失效非常频繁,只要有对一个表的某一条数据更新,这个表上所有的查询缓存都会被清空。

因此可能很费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。

比如:一个系统配置表,那这张表上的查询才适合使用查询缓存。

好在MySQL也提供了这种“按需使用”的方式。你可以将参数query_cache_type设置成DEMAND,这样对于默认的SQL语句都不使用查询缓存。

「注意」:MySQL 8.0版本直接将查询缓存的整块功能删掉了,标志着MySQL8.0开始彻底没有缓存这个功能了。

  • 解析器

如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL需要知道你要做什么,因此需要对SQL语句做解析。
分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条SQL语句,MySQL需要识别出里面的字符串分别是什么,代表什么。
做完了词法分析以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个SQL语句是否满足MySQL语法。
如果我们在拼写SQL时候,少了或者写错了某个字母,,就会收到“You have an error in your SQL syntax”的错误提醒。

比如下面这个案例:
sql_error

一般语法错误会提示第一个出现错误的位置,所以你要关注的是紧接“use near”的内容,仅供参考,有时候这个提示也不是非常靠谱。
经过分析器对SQL进行了分析,并且没有报错。那么此时就进入优化器中,对SQL进行优化。

  • 优化器
    优化器主要是在我们的数据库表中,如果存在多个多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序 。
    比如说:
SELECT a.id, b.id FROM t_user a join t_user_detail b WHERE a.id=b.user_id and a.user_name='田维常' and b.id=10001 

它会在条件查询上进行优化处理。
优化器处理完成过后,此时就已经确定了SQL的执行方案。然后继续进入执行器中。

  • 执行器
    首先,肯定是要判断权限,就是有没有权限执行这条SQL。工作中可能会对某些客户端进行权限控制。
    比如说:生产环境中,对于大部分开发人员都只开查询权限,没有增删改权限(部分小公司除外)。

without_permission

  • 存储引擎层

engine
这一层,我们可以理解为我们业务系统中的持久层。

存储引擎的概念是MySQL里面才有的,不是所有的关系型数据库都有存储引擎这个概念 。

数据库存储引擎是数据库底层软件组织,数据库管理系统(DBMS)使用数据引擎进行创建、查询、更新和删除数据。不同的存储引擎提供不同的存储机制、索引技巧、锁定水平等功能,使用不同的存储引擎,还可以获得特定的功能。现在许多不同的数据库管理系统都支持多种不同的数据引擎。

因为在关系数据库中数据的存储是以表的形式存储的,所以存储引擎也可以称为表类型(Table Type,即存储和操作此表的类型)。

- MySQL5.5版本(mysql 版本 < 5.5版本) 以前,默认使用的存储引擎是MyISAM 。
- MySQL5.5版本(mysql 版本 >= 5.5版本) 以后,默认使用的存储引擎是InnoDB 。

下面对部分相对使用多的引擎进行一个对比:

dbengine

在实际项目中,大多数使用InnoDB,然后是MyISAM,至于其他存储引擎使用的非常至少。
我们可以使用命令来查看MySQL 已提供什么存储引擎 :

show engies;

也可以通过命令来查看 MySQL 当前默认的存储引擎 :

show variables like '%storage_engine%';

MyISAM与 InnoDB引擎的区别
MySQL5.5 版本之前默认的存储引擎就是 MyISAM 存储引擎,MySQL 中比较多的系统表使用 MyISAM 存储引擎,系统临时表也会用到 MyISAM 存储引擎,
但是在 Mysql5.5 之后默认的存储引擎就是 InnoDB 存储引擎了。
compare

如何在两种存储引擎中进行选择?
 - 是否有事务操作?InnoDB。
 - 是否存储并发修改?InnoDB。
 - 是否追求快速查询且数据修改较少?MyISAM。
 - 是否使用全文索引?如果不引用第三方框架可以选择MyISAM但是可以选用第三方框架和InnDB效率会更高InnoDB 存储引擎主要有如下特点:  
 - 支持事务
 - 支持 4 个级别的事务隔离
 - 支持多版本读
 - 支持行级锁
 - 读写阻塞与事务隔离级别相关
 - 支持缓存既能缓存索引也能缓存数据
 - 整个表和主键以 Cluster 方式存储组成一颗平衡树

当然也不是说 InnoDB 一定就是好的在实际开发中还是要根据具体的场景来选择到底是使用 InnoDB 还是 MyISAMMyIASM(该引擎在 5.5 前的 MySQL 数据库中为默认存储引擎)特点:
 - MyISAM 没有提供对数据库事务的支持
 - 不支持行级锁和外键
 - 由于上一条导致当执行 INSERT 插入或 UPDATE 更新语句时即执行写操作需要锁定整个表所以会导致效率降低
 - MyISAM 保存了表的行数当执行 SELECT COUNT(*) FROM TABLE 可以直接读取相关值不用全表扫描速度快两者区别:
 - MyISAM 是非事务安全的 InnoDB 是事务安全的
 - MyISAM 锁的粒度是表级的 InnoDB 支持行级锁
 - MyISAM 支持全文类型索引 InnoDB  MySQL5.6 之前不支持全文索引 MySQL5.6 之后开始支持 FULLTEXT 索引了使用场景比较:

 - 如果要执行大量 select 操作应该选择 MyISAM
 - 如果要执行大量 insert  update 操作应该选择 InnoDB
 - 大尺寸的数据集趋向于选择 InnoDB 引擎因为它支持事务处理和故障恢复数据库的大小决定了故障恢复的时间长短InnoDB 可以利用事务日志进行数据恢复这会比较快主键查询在 InnoDB 引擎下也会相当快不过需要注意的是如果主键太长也会导致性能问题。
 - 相对来说InnoDB 在互联网公司使用更多一些
  • 系统文件存储层
    store

这一层,我们同样的可以理解为我们业务系统中的数据库。
系统文件存储层主要是负责将数据库的数据和日志存储在系统的文件中,同时完成与存储引擎的之间的打交道,是文件的物理存储层。
比如:数据文件、日志文件、pid文件、配置文件等。

  • 数据文件

「db.opt文件」:记录这个数据库的默认使用的字符集和校验规则。

「frm文件」:存储于边相关的元数据信息,包含表结构的定义信息等,每一张表都会有一个frm文件与之对应。

「MYD文件」:MyISAM存储引擎专用的文件,存储MyISAM表的数据信息,每一张MyISAM表都有有一个.MYD文件。

「MYI文件」:也是MyISAM存储引擎专用的文件,存放MyISAM表的索引相关信息,每一张MyISAM表都有对应的.MYI文件。

「ibd文件和ibdata文件」:存放InnoDB的数据文件(包括索引)。InnoDB存储引擎有两种表空间方式:独立表空间和共享表空间。

独享表空间使用ibd文件来存放数据,并且每一张InnoDB表存在与之对应的.ibd文件。
共享表空间使用ibdata文件,所有表共同使用一个或者多个.ibdata文件。

「ibdata1文件」:系统表空间数据文件,存储表元数据、Undo日志等。

「ib_logfile0、ib_logfile0文件」:Redo log日志文件。

  • 日志文件

错误日志:默认是开启状态,可以通过命令查看:

show variables like '%log_error%'; 

二进制日志binary log:记录了对MySQL数据库执行的更改操作,并且记录了语句的发生时间、执行耗时;但是不记录查询select、show等不修改数据的SQL。
主要用于数据库恢复和数据库主从复制。也是大家常说的binlog日志。

show variables like '%log_log%';//查看是否开启binlog日志记录。 
show variables like '%binllog%';//查看参数 
show binary logs;//查看日志文件 

慢查询日志:记录查询数据库超时的所有SQL,默认是10秒。

show variables like '%slow_query%'//查看是否开启慢查询日志记录。 
show variables '%long_query_time%'//查看时长 

通用查询日志:记录一般查询语句;

show variables like '%general%'
  • 配置文件

用于存放MySQL所有的配置信息的文件,比如:my.cnf、my.ini等。

「pid文件」
pid文件是mysqld应用程序在Linux或者Unix操作系统下的一个进程文件,和许多其他Linux或者Unix服务端程序一样,该文件放着自己的进程id。

「socket文件」
socket文件也是Linux和Unix操作系统下才有的,用户在Linux和Unix操作系统下客户端连接可以不通过TCP/IP网络而直接使用Unix socket来连接MySQL数据库。

  • SQL查询流程图
    sql_flow

2. 数据库的三范式是什么?

第一范式(1NF):列不可再分

1.每一列属性都是不可再分的属性值,确保每一列的原子性
2.两列的属性相近或相似或一样,尽量合并属性一样的列,确保不产生冗余数据

例:
有一个学生表,假设有两个字段分别是 name,address,而address内容写的是:江苏省南京市浦口区xxx街道xxx小区。如果这时来一个需求,需要按省市区分类,显然不符需求,这样的表结构也不是符合第一范式的。
应该设计成 name,province(省),city(市),area(区),address

第二范式(2NF)属性完全依赖于主键

第二范式(2NF)是在第一范式(1NF)的基础上建立起来的,即满足第二范式(2NF)必须先满足第一范式(1NF)。

第二范式(2NF)要求数据库表中的每个实例或行必须可以被惟一地区分。为实现区分通常需要为表加上一个列,以存储各个实例的惟一标识。这个惟一属性列被称为主键

每一行的数据只能与其中一列相关,即一行数据只做一件事,只要数据列中出现重复的数据,那么就要把表拆分开来。

例:
有一个订单表如下:
orderId(订单编号),roomId(房间号), name(联系人), phone(联系电话),idn(身份证)

如果这时候一个人同时订了好几个房间,就会变成一个订单编号对应多条数据,这样子联系人都是重复的,就会造成数据冗余,这时我们应该把拆分开来。

如:
订单表:
orderId(订单编号),roomId(房间号), peoId(联系人编号)

联系人表:
peoId(联系人编号),name(联系人), phone(联系电话),idn(身份证)

第三范式(3NF)属性不依赖于其它非主属性 属性直接依赖于主键

第二范式(3NF)是在第二范式(2NF)的基础上建立起来的,即满足第三范式(3NF)必须先满足第二范式(2NF)。

简单点意思就是对字段冗余性的约束,即任何字段不能由其他字段派生出来,它要求字段没有冗余。

例:

假设有一个员工(employee)表,它有九个属性:id(员工编号)、name(员工名称)、mobile(电话)、zip(邮编)、province(省份)、city(城市)、district(区县)、deptNo(所属部门编号)、deptName(所属部门名称)

员工表的province、city、district依赖于zip,而zip依赖于id,换句话说,province、city、district传递依赖于id,违反了 3NF 规则。为了满足第三范式的条件,可以将这个表拆分成employee和zip两个表,如下

table_form

为什么需要范式

数据库范式为数据库的设计、开发提供了一个可参考的典范,在许多教学材料中也是作为关键的课程内容。 那么范式的提出是为了解决什么问题?

  • 第一范式,要求将列尽可能最小的分割,希望消除某个列存储多个值的冗余的行为 比如用户表中的地址信息,拆分为省、市这种明确的字段,可以按独立的字段检索、查询
  • 第二范式,要求唯一的主键,且不存在对主键的部分依赖,希望消除表中存在冗余(多余)的列 比如订单表中的商品分类、详情信息,只需要由商品信息表存储一份即可。
  • 第三范式,要求没有间接依赖于主键的列,即仍然是希望消除表中冗余的列 比如用户表中不需要存储额外的 其所在城市的人口、城市特点等信息。 很明显,这些范式大都是为了消除冗余而提出的,即尽可能的减少存储成本。
  • 没有冗余的数据库设计可以做到。但是,没有冗余的数据库未必是最好的数据库,有时为了提高运行效率,就必须降低范式标准,适当保留冗余数据。具体做法是: 在概念数据模型设计时遵守第三范式,降低范式标准的工作放到物理数据模型设计时考虑。降低范式就是增加字段,允许冗余。

3. char 和 varchar 的区别?

char的长度是不可变的,而varchar的长度是可变的。

定义一个char[10]和varchar[10]。

如果存进去的是‘tao’,那么char所占的长度依然为10,除了字符‘tao’外,后面跟7个空格,varchar就立马把长度变为3了,取数据的时候,char类型的要用trim()去掉多余的空格,而varchar是不需要的。

char的存取速度还是要比varchar要快得多,因为其长度固定,方便程序的存储与查找。
char也为此付出的是空间的代价,因为其长度固定,所以难免会有多余的空格占位符占据空间,可谓是以空间换取时间效率。

varchar是以空间效率为首位。

存储方式
char的存储方式是:对英文字符(ASCII)占用1个字节,对一个汉字占用两个字节。
varchar的存储方式是:对每个英文字符占用2个字节,汉字也占用2个字节。

两者的存储数据都非unicode的字符数据。 nchar和nvarchar是存储的unicode字符串数据

4. varchar(10) 和 varchar(20) 的区别?

背景

许多使用MySQL的同学都会使用到varchar这个数据类型。初学者刚开始学习varchar时,一定记得varchar是个变长的类型这个知识点,所以很多初学者在设计表时,就会把varchar(X)的长度设置的非常长,目的也是为了保证以后有更长的数据存储时,能更好的兼容。

于是本来varchar(10)就可以满足当前的存储的长度需求了,改成了varchar(100)。

那么疑问来了:

既然是变长类型,varchar(10)和varchar(100)有什么区别?

先举个例子:如果要存储'hello12345'这个字符串,使用varchar(10)和varchar(100)存储,占用磁盘空间是一样的么?

答案是:占用磁盘的存储空间是一样的。

既然存储时磁盘占用空间一样,还有什么其他的区别吗?

虽然使用varchar(100)和varchar(10)存储'hello12345'字符串占用的磁盘空间一样,但是消耗的内存不一样,更长的列消耗的内存会更多。因为MySQL通常会分配固定大小的内存块来保存内部值。尤其是使用临时表进行排序会操作时,会消耗更多的内存。在使用磁盘进行排序时,也是一样。

所以此时varchar(100) 会消耗更多的内存。

varchar(10)和varchar(100)的优劣势是什么?

因为涉及到文件排序或者基于磁盘的临时表时,更长的列会消耗更多的内存,所以在使用使用时,我们不能太过浪费内存空间,还是需要评估实际使用的长度来设置字符的长度。推荐冗余10%的长度(因业务而异)。

所使用varchar(10)会更加节约内存空间,但是实际业务中字符长度一旦超过10就需要更改表结构,在表数据量特别大时,不易拓展。

而这时使用更长的列:varchar(100)无需更改表结构,业务拓展性更好。

5. 谈谈你对索引的理解?

索引的出现是为了提高数据的查询效率,就像书的目录一样。一本500页的书,如果你想快速找到其中的某一个知识点,在不借助目录的情况下,那我估计你可得找一会儿。同样,对于数据库的表而言,索引其实就是它的“目录”。

同样索引也会带来很多负面影响:创建索引和维护索引需要耗费时间,这个时间随着数据量的增加而增加;索引需要占用物理空间,不光是表需要占用数据空间,每个索引也需要占用物理空间;当对表进行增、删、改、的时候索引也要动态维护,这样就降低了数据的维护速度。

建立索引的原则

  • 在最频繁使用的、用以缩小查询范围的字段上建立索引;
  • 在频繁使用的、需要排序的字段上建立索引。

不适合建立索引的情况

  • 对于查询中很少涉及的列或者重复值比较多的列,不宜建立索引;
  • 对于一些特殊的数据类型,不宜建立索引,比如:文本字段(text)等。

6. 索引的底层使用的是什么数据结构?

B+树
为什么是B+树而不是B树?
bandbplus


bplus

可以看到:

  • B树在每个节点上都有卫星数据(数据表中的一行数据),而B+树只在叶子节点上有卫星数据。这意味着相同大小的磁盘扇区,B+树可以存储的叶子节点更多,磁盘IO次数更少;同样也意味着B+树的查找效率更稳定,而B树数据查询的最快时间复杂度是O(1)。
  • B树的每个节点只出现一次,B+树的所有节点都会出现在叶子节点中。B+树的所有叶子节点形成一个升序链表,适合区间范围查找,而B树则不适合。

MyISAM和InnoDB的B+树索引实现方式的区别(聚簇索引和非聚簇索引)
首先需要了解聚簇索引和非聚簇索引。

聚集索引与非聚集索引的区别是:叶节点是否存放一整行记录
InnoDB 主键使用的是聚簇索引,MyISAM 不管是主键索引,还是二级索引使用的都是非聚簇索引。 下图形象说明了聚簇索引表(InnoDB)和非聚簇索引(MyISAM)的区别:

cluster_non_cluster

  1. 对于非聚簇索引表来说(右图),表数据和索引是分成两部分存储的,主键索引和二级索引存储上没有任何区别。使用的是B+树作为索引的存储结构,所有的节点都是索引,叶子节点存储的是索引+索引对应的记录的数据。
  2. 对于聚簇索引表来说(左图),表数据是和主键一起存储的,主键索引的叶结点存储行数据(包含了主键值),二级索引的叶结点存储行的主键值。使用的是B+树作为索引的存储结构,非叶子节点都是索引关键字,但非叶子节点中的关键字中不存储对应记录的具体内容或内容地址。叶子节点上的数据是主键与具体记录(数据内容)。

聚簇索引的优缺点

  • 优点

    • 把相关数据保存在一起(比如用用户ID把用户的全部邮件聚集在一起),否则每次数据读取就可能导致一次磁盘IO
    • 数据访问更快,把索引和数据保存在同一个B+树中,通常在聚簇索引中获取数据比在非聚簇索引中查找更快
    • 使用覆盖查询可以直接利用页节点中的主键值
  • 缺点

    • 如果所有数据都可以放在内存中,顺序访问不再那么必要,聚簇索引没有优势
    • 插入速度依赖于插入顺序,随机插入会导致页分裂,造成空洞,使用OPTIMIZE TABLE重建表
    • 每次插入、更新、删除都需要维护索引的变化,代价很高
    • 二级索引可能比想象中大,因为在节点中包含了引用行的主键列

哈希索引
哈希索引基于哈希表实现,只有精确匹配索引所有列的查询才有效,这意味着,哈希索引适用于等值查询。

具体实现:对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码,哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。

在MySQL中,只有Memory引擎显式支持哈希索引,当然Memory引擎也支持B树索引。 PS:Memory引擎支持非唯一哈希索引,解决冲突的方式是以链表的形式存放多个哈希值相同的记录指针。

- 自适应哈希索引

InnoDB注意到某些索引值被使用得非常频繁时,会在内存中基于B+树索引之上再创建一个哈希索引,这样就让B+树索引也具有哈希索引的一些优点,比如快速的哈希查找。

7. 谈谈你对最左前缀原则的理解?

在mysql建立联合索引时会遵循最左前缀匹配的原则,即最左优先,在检索数据时从联合索引的最左边开始匹配,示例: 对列col1、列col2和列col3建一个联合索引

KEY test_col1_col2_col3 on test(col1,col2,col3);

联合索引 test_col1_col2_col3 实际建立了(col1)、(col1,col2)、(col,col2,col3)三个索引。

SELECT * FROM test WHERE col1=1AND clo2=2AND clo4=4

注意
索引的字段可以是任意顺序的,如:

SELECT * FROM test WHERE col1=1AND clo2=2SELECT * FROM test WHERE col2=2AND clo1=1

这两个查询语句都会用到索引(col1,col2),mysql创建联合索引的规则是首先会对联合合索引的最左边的,也就是第一个字段col1的数据进行排序,在第一个字段的排序基础上,然后再对后面第二个字段col2进行排序。其实就相当于实现了类似 order by col1 col2这样一种排序规则。

有人会疑惑第二个查询语句不符合最左前缀匹配:首先可以肯定是两个查询语句都包含索引(col1,col2)中的col1、col2两个字段,只是顺序不一样,查询条件一样,最后所查询的结果肯定是一样的。既然结果是一样的,到底以何种顺序的查询方式最好呢?此时我们可以借助mysql查询优化器explain,explain会纠正sql语句该以什么样的顺序执行效率最高,最后才生成真正的执行计划。

为什么要使用联合索引
减少开销。建一个联合索引(col1,col2,col3),实际相当于建了(col1),(col1,col2),(col1,col2,col3)三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。对于大量数据的表,使用联合索引会大大的减少开销!
覆盖索引。对联合索引(col1,col2,col3),如果有如下的sql: select col1,col2,col3 from test where col1=1 and col2=2。那么MySQL可以直接通过遍历索引取得数据,而无需回表,这减少了很多的随机io操作。减少io操作,特别的随机io其实是dba主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。
效率高。索引列越多,通过索引筛选出的数据越少。有1000W条数据的表,有如下sql:select from table where col1=1 and col2=2 and col3=3,假设假设每个条件可以筛选出10%的数据,如果只有单值索引,那么通过该索引能筛选出1000W10%=100w条数据,然后再回表从100w条数据中找到符合col2=2 and col3= 3的数据,然后再排序,再分页;如果是联合索引,通过索引筛选出1000w10% 10% *10%=1w,效率提升可想而知!

引申
对于联合索引(col1,col2,col3),查询语句SELECT * FROM test WHERE col2=2;是否能够触发索引?
大多数人都会说NO,实际上却是YES。
原因:

EXPLAIN SELECT * FROM test WHERE col2=2;
EXPLAIN SELECT * FROM test WHERE col1=1;

观察上述两个explain结果中的type字段。查询中分别是:

type: index type: ref

index:这种类型表示mysql会对整个该索引进行扫描。要想用到这种类型的索引,对这个索引并无特别要求,只要是索引,或者某个联合索引的一部分,mysql都可能会采用index类型的方式扫描。但是呢,缺点是效率不高,mysql会从索引中的第一个数据一个个的查找到最后一个数据,直到找到符合判断条件的某个索引。所以,上述语句会触发索引。
ref:这种类型表示mysql会根据特定的算法快速查找到某个符合条件的索引,而不是会对索引中每一个数据都进行一一的扫描判断,也就是所谓你平常理解的使用索引查询会更快的取出数据。而要想实现这种查找,索引却是有要求的,要实现这种能快速查找的算法,索引就要满足特定的数据结构。简单说,也就是索引字段的数据必须是有序的,才能实现这种类型的查找,才能利用到索引。

8. 怎么知道创建的索引有没有被使用到?或者说怎么才可以知道这条语句运行很慢的原因?

  • 使用explain命令来查看语句的执行计划,MySQL在执行某个语句之前,会将该语句过一遍查询优化器,之后会拿到对语句的分析,也就是执行计划,其中包含了很多信息。
  • 可以通过其中和索引相关的信息来分析是否命中了索引,比如:possible_key,key,key_len分别说明了此语句可能会使用的索引、实际使用的索引以及使用的索引长度

9. 什么情况下索引会失效?即查询不走索引?

看博客 notwork

10. 查询性能的优化方法?

看博客

11. 说一下 MySQL 的行锁和表锁?

对于行锁和表锁的含义区别,在面试中应该是高频出现的,我们应该对MySQL中的锁有一个系统的认识,更详细的需要自行查阅资料,本篇为概括性的总结回答。

MySQL常用引擎有MyISAM和InnoDB,而InnoDB是mysql默认的引擎。MyISAM不支持行锁,而InnoDB支持行锁和表锁。

如何加锁?
MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。

显式加锁:
上共享锁(读锁)的写法:lock in share mode,例如:

select  math from zje where math>60 lock in share mode;

上排它锁(写锁)的写法:for update,例如:

select math from zje where math >60 for update

表锁

不会出现死锁,发生锁冲突几率高,并发低。

MyISAM引擎
MyISAM在执行查询语句(select)前,会自动给涉及的所有表加读锁,在执行增删改操作前,会自动给涉及的表加写锁。

MySQL的表级锁有两种模式:

  • 表共享读锁
  • 表独占写锁

读锁会阻塞写,写锁会阻塞读和写

  • 对MyISAM表的读操作,不会阻塞其它进程对同一表的读请求,但会阻塞对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
  • 对MyISAM表的写操作,会阻塞其它进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。

MyISAM不适合做写为主表的引擎,因为写锁后,其它线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞

行锁

会出现死锁,发生锁冲突几率低,并发高。

在MySQL的InnoDB引擎支持行锁,与Oracle不同,MySQL的行锁是通过索引加载的,也就是说,行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描,行锁则无法实现,取而代之的是表锁,此时其它事务无法对当前表进行更新或插入操作。

CREATE TABLE `user` (
`name` VARCHAR(32) DEFAULT NULL,
`count` INT(11) DEFAULT NULL,
`id` INT(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8

-- 这里,我们建一个user表,主键为id



-- A通过主键执行插入操作,但事务未提交
update user set count=10 where id=1;
-- B在此时也执行更新操作
update user set count=10 where id=2;
-- 由于是通过主键选中的,为行级锁,A和B操作的不是同一行,B执行的操作是可以执行的



-- A通过name执行插入操作,但事务未提交
update user set count=10 where name='xxx';
-- B在此时也执行更新操作
update user set count=10 where id=2;
-- 由于是通过非主键或索引选中的,升级为为表级锁,
-- B则无法对该表进行更新或插入操作,只有当A提交事务后,B才会成功执行

for update

如果在一条select语句后加上for update,则查询到的数据会被加上一条排它锁,其它事务可以读取,但不能进行更新和插入操作

-- A用户对id=1的记录进行加锁
select * from user where id=1 for update;

-- B用户无法对该记录进行操作
update user set count=10 where id=1;

-- A用户commit以后则B用户可以对该记录进行操作

行锁的实现需要注意

  • 行锁必须有索引才能实现,否则会自动锁全表,那么就不是行锁了。
  • 两个事务不能锁同一个索引。
  • insert,delete,update在事务中都会自动默认加上排它锁。

行锁场景

A用户消费,service层先查询该用户的账户余额,若余额足够,则进行后续的扣款操作;这种情况查询的时候应该对该记录进行加锁。

否则,B用户在A用户查询后消费前先一步将A用户账号上的钱转走,而此时A用户已经进行了用户余额是否足够的判断,则可能会出现余额已经不足但却扣款成功的情况。

为了避免此情况,需要在A用户操作该记录的时候进行for update加锁

扩展:间隙锁

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内并不存在的记录,叫做间隙

InnoDB也会对这个"间隙"加锁,这种锁机制就是所谓的间隙锁

-- 用户A
update user set count=8 where id>2 and id<6

-- 用户B
update user set count=10 where id=5;

如果用户A在进行了上述操作后,事务还未提交,则B无法对2~6之间的记录进行更新或插入记录,会阻塞,当A将事务提交后,B的更新操作会执行。

12. InnoDB 存储引擎的锁的算法有哪些?

  • Record Lock:单个行记录上的锁
  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
  • Next-Key Lock:Record Lock + Gap Lock,锁定一个范围,并且锁定记录本身

13. MySQL 问题排查都有哪些手段?

  • 使用 show processlist 命令查看当前所有连接信息。
  • 使用 explain 命令查询 SQL 语句执行计划。
  • 开启慢查询日志,查看慢查询的 SQL。

14. MySQL 数据库 CPU 飙升到 500% 的话他怎么处理?

看博客

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published