Java

1、面向对象的特征有哪些方面?

  • 抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。
  • 继承:继承是从已有类得到继承信息创建新类的过程。提供继承信息的类被称为父类(超类、基类);得到继承信息的类被称为子类(派生类)。继承让变化中的软件系统有了一定的延续性,同时继承也是封装程序中可变因素的重要手段。
  • 封装:通常认为封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口。面向对象的本质就是将现实世界描绘成一系列完全自治、封闭的对象。我们在类中编写的方法就是对现实细节的一种封装;我们编写一个类就是对数据和数据操作的封装。可以说,封装就是隐藏一切可隐藏的东西,只向外界提供最简单的编程接口。
  • 多态性:多态性是指允许不同子类型的对象对同一消息作出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做了不同的事情。多态性分为编译时的多态性和运行时的多态性。如果将对象的方法视为对象向外界提供的服务,那么运行时的多态性可以解释为:当 A 系统访问 B 系统提供的服务时,B 系统有多种提供服务的方式,但一切对 A 系统来说都是透明的。方法重载(Overload)实现的是编译时的多态性(也称为前绑定),而方法重写(Override)实现的是运行时的多态性(也称为后绑定)。运行时的多态是面向对象最精髓的东西,要实现多态需要做两件事:
    • 方法重写(子类继承父类并重写父类中已有的或抽象的方法);
    • 对象造型(用父类型引用子类型对象,这样同样的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)。

2、访问修饰符 public、private、protected 以及不写(默认)时的区别?

当前类、同包子类、其他包类的成员不写访问修饰符时默认为 default,默认对于同一个包中的其他类相当于公开(public),对于不是同一个包中的其他类相当于私有(private)。

受保护(protected)对子类相当于公开,对不是同一包中的没有父子关系的类相当于私有。

Java 中,外部类的修饰符只能是 public 或默认,类的成员(包括内部类)的修饰符可以是以上四种。


3、String 是最基本的数据类型吗?

不是。

Java 中的基本数据类型只有 8 个:byte、short、int、long、float、double、char、boolean;除了基本类型(primitive type),剩下的都是引用类型(reference type),Java 5 以后引入的枚举类型也算是一种比较特殊的引用类型。


4、float f= 3.4; 是否正确?

不正确。

3.4 是双精度数,将双精度型(double)赋值给浮点型(float)属于下转型(down-casting,也称为窄化)会造成精度损失,因此需要强制类型转换 float f= (float) 3.4; 或者写成 float f= 3.4F;。


5、short s1= 1; s1= s1+ 1;有错吗?short s1= 1; s1+= 1; 有错吗?

前者有错,后者无错。

对于前者,由于 1 是 int 类型,因此 s1+ 1 运算结果也是 int 型,需要强制转换类型才能赋值给 short 型。

而后者可以正确编译,因为 s1+= 1 相当于 s1= (short(s1+ 1)) 其中有隐含的强制类型转换。


6、int 和 Integer 有什么区别?

Java 是一个近乎纯洁的面向对象编程语言,但是为了编程的方便还是引入了基本数据类型,但是为了能够将这些基本数据类型当成对象操作,Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int 的包装类就是 Integer。从 Java 5 开始引入了自动装箱 / 拆箱机制,使得二者可以相互转换。Java 为每个原始类型提供了包装类型:

原始类型:boolean,char,byte,short,int,long,float,double

包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double

Integer a= new Integer(3);
Integer b= 3;                       // 将 3 自动装箱成 Integer 类型
int c= 3;
System.out.println(a== b);          // false 两个引用没有引用同一对象
System.out.println(a== c);          // true a 自动拆箱成 int 类型再和 c 比较

特例如下:

Integer f1 = 100, f2 = 100, f3 = 150, f4 = 150;
System.out.println(f1 == f2);		// true
System.out.println(f3 == f4);		// false

这里四个变量都是 Integer 对象引用,所以上面的 == 运算比较的不是值而是引用。

装箱的本质是什么呢?当我们给一个 Integer 对象赋一个 int 值的时候,会调用 Integer 类的静态方法 valueOf。

简单的说,如果整型字面量的值在 -128 到 127 之间,那么不会 new 新的 Integer 对象,而是直接引用常量池中的 Integer 对象,所以上面 f3 和 f4 的结果是 false。


7、& 和 && 的区别?

& 运算符有两种用法:按位与逻辑与

&& 运算符是短路和运算。&& 之所以称为短路运算是因为,如果 && 左边的表达式的值是 false,右边的表达式会被直接短路掉,不会进行运算。

很多时候我们可能都需要用 && 而不是 &,例如在验证用户登陆时判定用户名不是 null 而且不是空字符串,应当写为:

username!= null && !username.equals("")

二者的顺序不能交换,更不能用 & 运算符,因为第一个条件如果不成立,根本不能进行字符串的 equals 比较,否则会发生 NullPointerException 异常。

注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。


8、解释内存中的栈(stack)、堆(heap)和方法区(method area)的用法。

通常定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用 JVM 中的栈空间;而通过 new 关键字和构造器创建的对象则放在堆空间。

堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器都采用分代收集算法,所以堆空间还可以细分为新生代和老生代,再具体一点可以分为 Eden、Survivor(又可分为 From Survivor 和 To Survivor)、Tenured;

方法区和堆都是各个线程共享的内存区域,用于存储已经被 JVM 加载的类信息、常量、静态变量、JIT 编译器编译后的代码等数据;

程序中的字面量(literal)如直接书写的 100、"Hello" 和常量都是放在常量池中,常量池是方法区的一部分。

栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,栈和堆的大小都可以通过 JVM 的启动参数来进行调整,栈空间用光了会引发 StackOverflowError,而堆和常量池空间不足则会引发 OutOfMemoryError。

String str= new String("Hello");

上面的语句创建了两个字符串对象:一个是方法区的 "Hello";一个是用 new 创建在堆上的对象。

变量 str 放在栈上,用 new 创建出来的字符串对象放在堆上,而 "Hello"这个字面量是放在方法区的。

补充 1:较新版本的 Java (从 Java 6 的某个更新开始)中,由于 JIT 编译器的发展和 "逃逸分析" 技术的逐渐成熟,栈上分配、标量替换等优化技术使得对象一定分配在堆上这件事情已经变得不那么绝对了。

补充 2:运行时常量池相对于 Class 文件常量池具有动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern() 方法就是这样的。

特例如下:

String s1 = new StringBuilder("go").append("od").toString();
System.out.println(s1.intern() == s1);		// true
String s2 = new StringBuilder("ja").append("va").toString();
System.out.println(s2.intern() == s2);		// false

原因:Java 在新建一个调用 String.intern() 的时候会获得这个常量池中的引用所指向的对象,所以第一个对象由于 Java 常量池中没有这个对象,String.intern() 指向的为创建的 "good" 对象,故返回true;而 Java 常量池中本身就有一个 "Java" 对象,所以会指向常量池中的对象,故返回 false。

如果将代码改为如下,则返回两个 false:

public class Main {
    public final static String s="good";
    public static void main(String[] args) {

        String s1=new StringBuilder("go").append("od").toString();
        System.out.println(s1==s1.intern());
        String s2=new StringBuilder("ja").append("va").toString();
        System.out.println(s2==s2.intern());
    }
}

9、switch 能否作用在 byte 上,能否作用在 long 上,能否作用在 String 上?

在 Java 5 以前,switch(expr) 中,expr 只能是 byte、short、char、int。

从 Java 5 开始,Java 中引入了枚举类型,expr 也可以是 enum 类型。

从 Java 7 开始,expr 还可以是字符串(String),但是长整型(long)在目前所有的版本中都是不可以的。


10、两个对象值相同(x.equals(y)== true),但却可有不同的 hash code,这句话对不对?

不对。

如果两个对象 x 和 y 满足 x.equals(y)== true,它们的哈希码(hash code)应当相同。Java 对于 equals 方法和 hashCode 方法是这样规定的:

  • 如果两个对象相同(equals 方法返回 true),那么它们的 hashCode 值一定要相同。
  • 如果两个对象的 hashCode 相同,它们并不一定相同。

当然,你未必要按照要求去做,但是如果违背了上述原则就会发现在使用容器时,相同的对象可以出现在 Set 集合中,同时增加新元素的效率会大大下降(对于使用哈希存储的系统,如果哈希码频繁的冲突将会造成存取性能急剧下降)。

补充:首先 equals 方法必须满足:

  • 自反性(x.equals(x) 必须返回 true);

  • 对称性(x.equals(y) 返回 true 时,y.equals(x) 也必须返回 true);

  • 传递性(x.equals(x) 和 y.equals(z) 都返回 true 时,x.equals(z) 也必须返回 true);

  • 一致性(当 x 和 y 引用的对象信息没有被修改时,多次调用 x.equals(y) 应该得到同样的返回值),而且对于任何非 null 值的引用 x,x.equals(null) 必须返回 false。

实现高质量的 equals 方法的诀窍包括:

  • 使用 == 操作符检查 "参数是否为这个对象的引用";
  • 使用 instanceof 操作符检查 "参数是否为正确的类型";
  • 对于类中的关键属性,检查参数传入对象的属性是否与之相匹配;
  • 编写完 equals 方法后,问自己它是否满足对称性、传递性、一致性;
  • 重写 equals 时总是要重写 hashCode;
  • 不要将 equals 方法参数中的 Object 对象替换为其他的类型,在重写时不要忘掉 @Override 注解。

11、当一个对象被当作参数传递到一个方法后,此方法可改变这个对象的属性,并可返回变化后的结果,那么这里到底是值传递还是引用传递?

值传递。

Java 语言的方法调用只支持参数的值传递。

当一个对象实例作为一个参数被传递到方法中时,参数的值就是对该对象的引用。对象的属性可以在被调用过程中被发现,但对对象引用的改变是不会影响到调用者的。

C++ 和 C# 中可以通过传引用或输出参数来改变传入的参数的值。在 C# 中可以编写如下所示的代码,但是在 Java 中却做不到:

using System;
namespace CS01 {
    class Program {
        public static void swap(ref int x, ref int y) {
            int temp= x;
            x= y; 
            y= temp;
        }
        public static void Main(string[] args) {
            int a= 5, b= 10;
            swap(ref a, ref b);			// a= 10, b= 5;
            Console.WriteLine("a= {0}, b= {1}", a, b);
            Console.ReadKey();
        }
    }
}

说明:Java 中没有传引用实在是非常的不方便,这一点在 Java 8 中仍然没有得到改进,正是如此在 Java 编写的代码中才会出现大量的 Wrapper 类(将需要通过方法调用修改的引用置于一个 Wrapper 类中,再将 Wrapper 对象传入方法),这样的做法只会让代码变得臃肿。


12、String 和 StringBuilder、StringBuffer 的区别?

Java 平台提供了两种类型的字符串:String 和 StringBuffer / StringBuilder,它们可以存储和操作字符串。

其中 String 是只读字符串,也就意味者 String 引用的字符串内容是不能被改变的。而 StringBuffer / StringBuilder 类表示的字符串对象可以直接进行修改。StringBuilder 是 Java 5 中引入的,它和 StringBuffer 的方法完全相同,区别在于它是在单线程环境下使用的,因为它的所有方法都没有被 synchronized 修饰,因此它的效率也比 StringBuffer 要高。

String s1 = "Programming";
String s2 = new String("Programming");
String s3 = "Program";
String s4 = "ming";
String s5 = "Program" + "ming";
String s6 = s3 + s4;
System.out.println(s1 == s2);			// false
System.out.println(s1 == s5);			// true
System.out.println(s1 == s6);			// false
System.out.println(s1 == s6.intern());	// true
System.out.println(s2 == s2.intern());	// false
System.out.println(s1 == s2.intern());	// true

String 对象的 intern 方法会得到字符串对象在常量池中对应的版本的引用(如果常量池中有一个字符串与 String 对象的 equals 结果是 true),如果常量池中没有对应的字符串,则该字符串将被添加到常量池中,然后返回常量池中字符串的引用。

字符串的 + 操作其本质是创建了 StringBuilder 对象进行 append 操作,然后将拼接后的 StringBuilder 对象用 toString 方法处理成 String 对象,这一点可以用 javap -c StringEqualTest.class 命令获得 class 文件对应的 JVM 字节码指令就可以看出来。


13、重载(Overload)和重写(Override)的区别。重载的方法能否根据返回类型进行区分?

方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载;

重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。

重载对返回类型没有特殊的要求。


14、描述一下 JVM 加载 class 文件的原理机制?

JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的 .class 文件中的数据读入到内存中,通常是创建一个字节数组读入 .class 文件,然后产生与所加载类对应的 Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对类进行初始化,包括:1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2)如果类中存在初始化语句,就依次执行这些初始化语句。

类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。

从 Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM),PDM 更好的保证了 Java 平台的安全性。在该机制中,JVM 自带的 BootStrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 BootStrap 的引用。下面是关于几个类加载器的说明:

  • BootStrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
  • Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 BootStrap。
  • System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性 java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。

15、char 型变量中能不能存贮一个中文汉字,为什么?

可以。

因为 Java 中使用的编码是 Unicode(不选择任何特定的编码,直接使用字符在字符集中的编号,这是统一的唯一方法),一个 char 类型占 2个字节(16 比特),所以放一个中文是没问题的。

补充:使用 Unicode 意味者字符在 JVM 内部和外部有不同的表现形式,在 JVM 内部都是 Unicode,当这个字符被从 JVM 内部转移到外部时(例如存入文件系统中),需要进行编码转换。所以 Java 中有字节流和字符流,以及在字符流和字节流之间进行转换的转换流,如 InputStreamReader 和 OutputStreamReader,这两个类是字节流和字符流之间的适配器类,承担了编码转换的任务;对于 C 程序员来说,要完成这样的编码转换恐怕要依赖于 union(联合体 / 共用体)共享内存的特征来实现了。


16、抽象类(abstract class)和接口(interface)有什么异同?

抽象类和接口都不能够实例化,但可以定义抽象类和接口类型的引用。一个类如果继承了某个抽象类或者实现了某个接口都需要对其中的抽象方法全部进行实现,否则该类仍然需要被声明为抽象类。接口比抽象类更加抽象,因为抽象类中可以定义构造器,可以有抽象方法和具体方法,而接口中不能定义构造器而且其中的方法全部都是抽象方法。抽象类中的成员可以是 private、default、protected、public 的,而接口中的成员全都是 public 的。抽象类中可以定义成员变量,而接口中定义的成员变量实际上都是常量。有抽象方法的类必须被声明为抽象类,而抽象类未必要有抽象方法。


17、Java 中会存在内存泄漏吗?简单描述。

理论上 Java 因为有垃圾回收机制(GC)不会存在内存泄漏问题(这也是 Java 被广泛使用于服务器端编程的一个重要原因);然而在实际开发中,可能会存在无用但可达的对象,这些对象不能被 GC 回收,因此也会导致内存泄漏的发生。例如 Hinbernate 的 Session(一级缓存)中的对象属于持久态,垃圾回收器是不会回收这些对象的,然而这些对象中可能存在无用的垃圾对象,如果不及时关闭(close)或清空(flush)一级缓存就可能导致内存泄漏。下面例子中的代码也会导致内存泄漏:

import java.util.Arrays;
import java.util.EmptyStackException;
public class MyStack<T> {
    private T[] elements;
    private int size = 0;
    private static final int INIT_CAPACITY = 16;
    public MyStack() {
        elements = (T[]) new Object[INIT_CAPACITY];
    }
    public void push(T elem) {
        ensureCapacity();
        elements[size++] = elem;
    }
    public T pop() {
        if(size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }
    private void ensureCapacity() {
        if(elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

上面的代码实现了一个栈(先进后出(FILO))结构,乍看一下似乎没有什么明显的问题,它甚至可以通过编写的各种单元测试。然而其中的 pop 方法却存在内存泄漏的问题,当我们用 pop 方法弹出栈中的对象时,该对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,因为栈内部维护着对这些对象的过期引用(obsolete reference)。在支持垃圾回收的语言中,内存泄漏是很隐蔽的,这种内存泄漏其实就是无意识的对象保持。如果一个对象引用被无意识的保留起来了,那么垃圾回收器不会处理这个对象,也不会处理该对象引用的其他对象,即使这样的对象只有少数几个,也可能会导致很多的对象被排除在垃圾回收之外,从而对性能造成重大影响,极端情况下会引发 Disk Paging(物理内存与硬盘的虚拟内存交换数据),甚至造成 OutOfMemoryError。


18、抽象的(abstract)方法是否可同时是静态的(static),是否可同时是本地方法(native),是否可同时被 synchronized 修饰?

都不能。

抽象方法需要子类重写,而静态的方法是无法被重写的,因此二者是矛盾的。本地方法是由本地代码(如 C 代码)实现的方法,而抽象方法是没有实现的,也是矛盾的。syschronized 和方法的实现细节有关,抽象方法不涉及实现细节,因此也是相互矛盾的。


19、阐述静态变量和实例变量的区别。

静态变量是被 static 修饰符修饰的变量,也称为类变量,它属于类,不属于类的任何一个对象,一个类不管创建多少个对象,静态变量在内存中有且仅有一个拷贝;

实例变量必须依存于某一实例,需要先创建对象然后通过对象才能访问到它。静态变量可以实现让多个对象共享内存。

补充:在 Java 开发中,上下文类和工具类中通常会有大量的静态成员。


20、是否可以从一个静态(static)方法内部发出对非静态(non-static)方法的调用?

不可以。

静态方法只能访问静态成员,因为非静态方法的调用要先创建对象,在调用静态方法时可能对象并没有被初始化。


21、如何实现对象克隆?

  • 实现 Cloneable 接口并重写 Object 类中的 clone() 方法;
  • 实现 Serializable 接口,通过对象的序列化和反序列化实现克隆,可以实现真正的深度克隆。

注意:基于序列化和反序列化实现的克隆不仅仅是深度克隆,更重要的是通过泛型限定,可以检查出要克隆的对象是否支持序列化,这项检查是编译器完成的,不是在运行时抛出异常,这种是方案明显优于使用 Object 类的 clone 方法克隆对象。让问题在编译的时候暴露出来总是好过把问题留到运行时。


22、GC 是什么?为什么要有 GC?

GC 是垃圾收集的意思,内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。Java 程序员不同担心内存管理,因为垃圾收集器会自动进行管理。要请求垃圾收集,可以调用下面的方法之一:System.gc()Runtime.getRuntime().gc()。但 JVM 可以屏蔽掉显示的垃圾回收调用。垃圾回收可以有效的防止内存泄漏,有效的使用可以使用的内存。垃圾回收器通常是作为一个单独的低优先级的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。在 Java 诞生初期,垃圾回收是 Java 最大的亮点之一,因为服务器端的编程需要有效的防止内存泄漏问题,然而时过境迁,如今 Java 的垃圾回收机制已经成为被诟病的东西。移动智能终端用户通常觉得 IOS 的系统比 Android 系统有更好的用户体验,其中一个深层次的原因就在于 Android 系统中垃圾回收的不可预知性。

补充:垃圾回收机制有很多种,包括:分代复制垃圾回收、标记垃圾回收、增量垃圾回收等方式。标准的 Java 进程既有栈又有堆。栈保存了原始型局部变量,堆保存了要创建的对象。Java 平台对堆内存回收和再利用的基本算法被称为标记清除,但是 Java 对其进行了改进,采用 "分代式垃圾收集"。这种方法会跟 Java 对象的生命周期将堆内存划分为不同的区域,在垃圾收集过程中,可能会将对象移动到不同区域:

  • 伊甸园(Eden):这是对象最初诞生的区域,并且对大多数对象来说,这里是它们唯一存在过的区域。
  • 幸存者乐园(Survivor):从伊甸园幸存下来的对象会被挪到这里。
  • 终身颐养院(Tenured):这是足够老的幸存对象的归宿。年轻代收集(Minor-GC)过程是不会触及这个地方的。当年轻代收集不能把对象放进终身颐养园时,就会触发一次完全收集(Major-GC),这里可能还会牵扯到压缩,以便为大对象腾出足够的空间。

与垃圾回收相关的 JVM 参数:

  • -Xms / -Xmx:堆的初始大小 / 堆的最大大小
  • -Xmn:堆中年轻代的大小
  • -XX:-DisableExplicitGC:让 System.gc() 不产生任何作用
  • -XX:+PrintGCDetails:打印 GC 的细节
  • -XX:printGCDateStamps:打印 GC 操作的时间戳
  • -XX:NewSize / XX:MaxNewSize:设置新生代大小 / 新生代最大大小
  • -XX:NewRatio:可以设置老生代和新生代的比例
  • -XX:PrintTenuringDistribution:设置每次新生代 GC 后输出幸存者乐园中对象年龄的分布
  • -XX:InitialTenuringThreshold / -XX:MaxTenuringThreshold:设置老年代阈值的初始值和最大值
  • -XX:TargetSurvivorRatio:设置幸存区的目标使用率

23、JDK、JRE、JVM 及 JIT 有什么区别:

JDK:Java Development Kit 简称,Java 开发工具包,提供了 java 的开发环境和运行环境。
JRE:Java Runtime Environment 的简称,Java 运行环境,为 java 的运行提供了所需环境。具体来说 JDK 其实包含了 JRE,同时还包含了编译 Java 源码的编译器 javac,还包含了很多 Java 程序调试和分析的工具。

JVM:Java Virtual Machine 的简称,虚拟机,运行 Java 应用。

JIT:Java In Time Compilation 的简称,即时编译,当代码执行的次数超过一定的阈值,会将 Java 字节码转换为本地代码,如:主要的热点代码会被转换为本地代码这样有利大幅度提高 Java 应用的性能。

简单来说:如果你需要运行 Java 程序,只需安装 JRE 就可以了;如果你需要编写 Java 程序,需要安装 JDK。


24、指出下面程序的运行结果

class A {
    static {
        System.out.print("1");
    }
    {
        System.out.print("3");
    }
    public A() {
    	System.out.print("2");
    }
}
class B extends A{
    static {
        System.out.print("a");
    }
    {
        System.out.print("c");
    }
    public B() {
    	System.out.print("b");
    }
}
public class Hello {
    public static void main(String[] args) {
        A ab = new B();			// 1a32cb
        ab = new B();			// 32cb
    }
}

执行结果:1a32cb32cb。

创建对象时构造器的调用顺序是:先初始化静态成员,然后调用父类构造器,再初始化非静态成员,最后调用自身构造器。


25、比较一下 Java 和 JavaScript

JavaScript 与 Java 是两个公司开发的两个不同的产品。

Java 是原 SunMicrosystems 公司推出的面向对象的程序设计语言,特别适合于互联网应用程序开发;而 JavaScript 是 Netscape 公司的产品,为了扩展 Netscape 浏览器的功能而开发的一种可以嵌入 Web 页面中运行的基于对象和事件驱动的解释性语言。JavaScript 的前身是 LiveScript;而 Java 的前身是 0ak 语言。

下面对两种语言间的异同作如下比较:

  • 基于对象和面向对象:Java 是一种真正的面向对象的语言,即使是开发简单的程序,必须设计对象;JavaScript 是种脚本语言,它可以用来制作与网络无关的,与用户交互作用的复杂软件。它是一种基于对象(Object-Based)和事件驱动(Event-Driven)的编程语言,因而它本身提供了非常丰富的内部对象供设计人员使用。
  • 解释和编译:Java 的源代码在执行之前,必须经过编译。JavaScript 是一种解释性编程语言,其源代码不需经过编译,由浏览器解释执行。(目前的浏览器几乎都使用了 JIT(即时编译)技术来提升 JavaScript 的运行效率)
  • 强类型变量和类型弱变量:Java 采用强类型变量检查,即所有变量在编译之前必须作声明;JavaScript 种变量是弱类型的,甚至在使用变量前可以不作声明,JavaScript 的解释器在运行时检查推断其数据类型。
  • 代码格式不一样。

补充:上面列出的四点是网上流传的所谓的标准答案。其实 Java 和 JavaScript 最重要的区别是一个是静态语言,一个是动态语言。目前的编程语言的发展趋势是函数式语言和动态语言。在 Java 中类(class)是一等公民,而 JavaScript 中函数(function)是一等公民,因此 JavaScript 支持函数式编程,可以使用 Lambda 函数和闭包(closure),当然 Java 8 也开始支持函数式编程,提供了对 Lambda 表达式以及函数式接口的支持。


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

会执行,在方法返回调用者前执行。

注意:在 finally 种改变返回值的做法是不好的,因为如果存在 finally 代码块,try 中的 return 语句不会立马返回调用者,而是记录下返回值待 finally 代码块执行完毕之后再向调用者返回其值,然后如果在 finally 中修改了返回值,就会返回修改后的值。显然,在 finally 中返回或者修改返回值会对程序造成很大的困扰,C# 中直接用编译错误的方式来显式,Java 中也可以通过提升编译器的语法检查级别来产生警告或错误。


27、Java 语言如何进行异常处理,关键字:throws、throw、try、catch、finally 分别如何使用?

Java 通过面向对象的方法进行异常处理,把各种不同的异常进行分类,并提供了良好的接口。在 Java 中,每个异常都是一个对象,它是 Throwable 类或其子类的实例。当一个方法出现异常后便抛出一个异常对象,该对象中包含有异常信息,调用这个对象的方法可以捕获到这个异常并可以对其进行处理。

Java 的异常处理是通过 5 个关键词来实现的:try、catch、throw、throws 和 finally。

一般情况下是用 try 来执行一段程序,如果系统会抛出(throw)一个异常对象,可以通过它的类型来捕获(catch)它,或通过总是执行代码块(finally)来处理:try 用来指定一块预防所有异常的程序;catch 子句紧跟在 try 块后面,用来指定想要捕获的异常的类型;throw 语句用来明确地抛出一个异常;throws 用来声明一个方法可能抛出的各种异常;finally 为确保一段代码不管发生什么异常状况都要被执行。try 语句可以嵌套,每当遇到一个 try 语句,异常的结构就会被放入异常栈中,直到所有的 try 语句都完成。如果下一级的 try 语句没有对某种异常进行处理,异常栈就会执行出栈操作,直到遇到有处理这种异常的 try 语句或者最终将异常抛给 JVM。


28、运行时异常与受检异常有何异同?

异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误,只要程序设计得没有问题通常就不会发生。受检异常跟程序运行的上下文环境有关,即使程序设计无误,仍然可能因使用的问题而引发。Java 编译器要求方法必须声明抛出可能发生的受检异常,但是并不要求必须声明抛出未被捕获的运行时异常。异常和继承一样,是面向对象程序设计中经常被滥用的东西,在 Effective Java 中对异常的使用给出了以下指导原则:

  • 不要将异常处理用于正常的控制流(设计良好的 API 不应该强迫它的调用者为了正常的控制流而使用异常)
  • 对可以恢复的情况使用受检异常,对编程错误使用运行时异常
  • 避免不必要的使用受检异常(可以通过一些状态检测手段来避免异常的发生)
  • 优先使用标准的异常
  • 每个方法抛出的异常都要有文档
  • 保持异常的原子性
  • 不要在 catch 中忽略掉捕获到的异常

常见的运行时异常:

  • ArithmeticException(算术异常)
  • ClassCastException(类转换异常)
  • IllegalArgumentException(非法参数异常)
  • IndexOutOfBoundsException(下标越界异常)
  • NullPointerException(空指针异常)
  • SecurityException(安全异常)
  • NumberFormatException(数字格式异常)
  • ArrayStoreException(向数组中存放与声明类型不兼容对象异常)

29、阐述 final、finally、finalize 的区别

  • final:修饰符(关键字)有三种用法:如果一个类被声明为 final,意味着它不能再派生出新的子类,即不能被继承。将变量声明为 final,可以保证它们在使用中不被改变,被声明为 final 的变量必须在声明时给定初值,而在以后的引用中只能读取不可修改。被声明为 final 的方法也同样只能使用,不能在子类中被重写。
  • finally:通常放在 try ... catch ... 的后面构造总是执行代码块,这就意味着程序无论正常执行还是发生异常,这里的代码只要 JVM 不关闭都能执行,可以将释放外部资源的代码写在 finally 块中。
  • finalize:Object 类中定义的方法,Java 中允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写 finalize() 方法可以整理系统资源或者执行其他清理工作。

30、里氏代换原则[能使用父类型的地方一定能使用子类型]

class Annoyance extends Exception {}
class Sneeze extends Annoyance {}

class Human {
	public static void main(String[] args) throws Exception {
		try {
			try {
				throw new Sneeze();
			}
			catch ( Annoyance a ) {
				System.out.println("Caught Annoyance");
				throw a;
			}
		}
		catch ( Sneeze s ) {
			System.out.println("Caught Sneeze");
			return ;
		}
		catch ( Exception s ) {
			System.out.println("Caught Exception");
			return ;
		}
		finally {
			System.out.println("Hello World!");
		}
	}
}

输出结果:

Caught Annoyance
Caught Sneeze
Hello World!

抓取 Annoyance 类型异常的 catch 块能够捕获 try 块中抛出的 Sneeze 类型的异常。


31、阐述 ArrayList、Vector、LinkedList 的存储性能和特性。

ArrayList 和 Vector 都是使用数组方式存储数据,此数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据慢,Vector 中的方法由于添加了 synchronized 修饰,因此 Vector 是线程安全的容器,但性能上较 ArrayList 差,因此已经是 Java 中的遗留容器。LinkedList 使用双向链表实现存储(将内存中零散的内存单元通过附加的引用关联起来,形成一个可以按序号索引的线性结构,这种链式存储方式与数组的连续存放方式相比,内存的利用率更高),按序号索引数据需要进行前向或后向遍历,但是插入数据时只需要记录本项的前后项即可,所以插入速度较快。Vector 属于遗留容器,已经不推荐使用,但是由于 ArrayList 和 LinkedList 都是非线性安全的,如果遇到多个线程操作同一个容器的场景,则可以通过工具类 Collections 中的 synchronizedList 方法将其转换成线程安全的容器后再使用(这是对装潢模式的应用,将已有对象传入另一个类的构造器中创建新的对象来增强实现)。


32、TreaMap 和 TreeSet 在排序时如何比较元素?Collections 工具类中的 sort() 方法如何比较元素?

TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo() 方法,当插入元素时会回调该方法比较元素的大小。

TreeMap 要求存放的键值对映射的键必须实现 Comparable 接口从而根据键对元素进行排序。Collections 工具类的 sort 方法有两种重载的形式,第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比较;第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是 Comparator 接口的子类型(需要重写 Compare 方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。


33、Thread 类的 sleep() 方法和对象的 wait() 方法都可以让线程暂停执行,它们有什么区别?

sleep() 方法(休眠)是线程类(Thread)的静态方法,调用此方法会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,因此休眠时间结束后会自动恢复(线程回到就绪状态)。

wait() 是 Object 类的方法,调用对象的 wait() 方法导致当前线程放弃对象的锁(线程暂停执行),进入对象的 等待池(wait pool),只有调用对象的 notify() 方法(或 notifyAll() 方法)时才能唤醒等待池中的线程进入 等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。

补充:线程与进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是操作系统进行资源分配和调度的一个独立单位;线程是进程的一个实体,是 CPU 调度和分派的基本单位,是比进程更小的能独立 运行的基本单位。线程的划分尺度小于进程,这使得多线程程序的并发性高;进程在执行时通常拥有独立的内存单元,而线程之间可以共享内存。使用多线程的编程通常能够带来更好的性能和用户体验,但是多线程的程序对于其他程序是不友好的,因为它可能占用了更多的 CPU 资源。当然,也不是线程越多,程序的性能就越好,因为线程之间的调度和切换也会浪费 CPU 时间。


34、线程的 sleep() 方法和 yield() 方法有什么区别?

  • sleep() 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield() 方法只会给相同优先级或更高优先级的线程以运行的机会;
  • 线程执行 sleep() 方法后转入阻塞(blocked)状态,而执行 yield() 方法后转入就绪(ready)状态;
  • sleep() 方法声明抛出 InterruptedException,而 yield() 方法没有声明任何异常;
  • sleep() 方法比 yield() 方法(跟操作系统 CPU 调度相关)具有更好的可移植性。

35、当一个线程进入一个对象的 Synchronized 方法 A 之后,其它线程是否可进入此对象的 Synchronized 方法 B?

不能。

其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的 synchronized 修饰符要求执行方法时要获得对象的锁,如果已经进入 A 方法说明对象锁已经被取走,那么试图进入 B 方法的线程就只能在 等锁池 中等待对象的锁。


36、与线程同步以及线程调度相关的方法

  • wait():使一个线程处于等待(阻塞)状态,并且释放所有的对象的锁;
  • sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常;
  • notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;
  • notifyAll():唤醒所有处于等待状态的线程,该方法不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;

37、什么是线程池?

在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在 Java 中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁,这就是 ”池化资源” 技术产生的原因。

线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。Java 5+ 中的 Executor 接口定义一个执行线程的工具。它的子类型即线程池接口是 ExecutorService。要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,因此在工具类 Executors 面提供了一些静态工厂方法, 生成一些常用的线程池,如下所示:

  • newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
  • newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
  • newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。
  • newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
  • newSingleThreadExecutor:创建一个单线程的线程池。此线程池支持定时以及周期性执行任务的需求。

38、简述 Synchronized 和 java.util.concurrent.locks.Lock 的异同?

Lock 是 Java 5 以后引入的新的 API,和关键字 synchronized 相比主要相同点:Lock 能完成 synchronized 所实现的所有功能;主要不同点:Lock 有比 synchronized 更精确的线程语义和更好的性能,而且不强制性的要求一定要获得锁。synchronized 会自动释放锁,而 Lock 一定要求程序员手工释放,并且最好在 finally 块中释放(这是释放外部资源的最好的地方)。


39、Java 中如何实现序列化,有什么意义?

序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决对象流读写操作时可能引发的问题(如果不进行序列化可能会存在数据乱序的问题)。要实现序列化,需要让一个类实现 Serializable 接口,该接口是一个标识性接口,标注该类对象是可被序列化的,然后使用一个输出流来构造一个对象输出流并通过 writeObject(Object) 方法就可以将实现对象写出(即保存其状态);如果需要反序列化则可以用一个输入流建立对象输入流,然后通过 readObject 方法从流中读取对象。序列化除了能够实现对象的持久化之外,还能够用于对象的 深度克隆


40、输入一个文件名和一个字符串,统计这个字符串在这个文件中出现的次数。

import java.io.BufferedReader;
import java.io.FileReader;
 
public final class MyUtil {
 
    // 工具类中的方法都是静态方式访问的因此将构造器私有不允许创建对象(绝对好习惯)
    private MyUtil() {
        throw new AssertionError();
    }
 
    /**
     * 统计给定文件中给定字符串的出现次数
     * 
     * @param filename  文件名
     * @param word 字符串
     * @return 字符串在文件中出现的次数
     */
    public static int countWordInFile(String filename, String word) {
        int counter = 0;
        try (FileReader fr = new FileReader(filename)) {
            try (BufferedReader br = new BufferedReader(fr)) {
                String line = null;
                while ((line = br.readLine()) != null) {
                    int index = -1;
                    while (line.length() >= word.length() && (index = line.indexOf(word)) >= 0) {
                        counter++;
                        line = line.substring(index + word.length());
                    }
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return counter;
    }
}

41、列出一个目录下所有的文件

import java.io.File;

class MyUtil {
    public static void main(String[] args) {
        File f = new File("/Users/Hao/Downloads");
        for (File temp : f.listFiles()) {
            if (temp.isFile()) {
                System.out.println(temp.getName());
            }
        }
    }
}

如果需要对文件夹继续展开,代码如下所示:

import java.io.File;

class MyUtil {
    public static void main(String[] args) {
        showDirectory(new File("/Users/Hao/Downloads"));
    }

    public static void showDirectory(File f) {
        _walkDirectory(f, 0);
    }

    private static void _walkDirectory(File f, int level) {
        if (f.isDirectory()) {
            for (File temp : f.listFiles()) {
                _walkDirectory(temp, level + 1);
            }
        } else {
            for (int i = 0; i < level - 1; i++) {
                System.out.print("t");
            }
            System.out.println(f.getName());
        }
    }
}

42、XML 文档定义有几种形式?它们之间有何本质区别?解析 XML 文档有哪几种方式?

XML 文档定义分为 DTD 和 Schema 两种形式,二者都是对 XML 语法的约束,其本质区别在于 Schema 本身也是一个 XML 文件,可以被 XML 解析器解析,而且可以为 XML 承载的数据定义类型,约束能力较之 DTD 更强大。对 XML 的解析主要有 DOM(文档对象模型,Document Object Model)、SAX(Simple API forXML)和 StAX(Java 6 中引入的新的解析 XML 的方式,Streaming API for XML),其中 DOM 处理大型文件时其性能下降的非常厉害,这个问题是由 DOM 树结构占用的内存较多造成的,而且 DOM 解析方式必须在解析文件之前把整个文档装入内存,适合对 XML 的随机访问(典型的用空间换取时间的策略);SAX 是事件驱动型的 XML 解析方式,它顺序读取 XML 文件,不需要一次全部装载整个文件。当遇到像文件开头,文档结束,或者标签开头与标签结束时,它会触发一个事件,用户通过事件回调代码来处理 XML 文件,适合对 XML 的顺序访问;顾名思义, StAX 把重点放在流上,实际上 StAX 与其他解析方式的本质区别就在于应用程序能够把 XML 作为一个事件流来处理。将 XML 作为一组事件来处理的想法并不新颖( SAX 就是这样做的),但不同之处在于 StAX 允许应用程序代码把这些事件逐个拉出来,而不用提供在解析器方便时从解析器中接收事件的处理程序。


43、阐述 JDBC 操作数据库的步骤

下面的代码以连接本机的 Oracle 数据库为例,演示 JDBC 操作数据库的步骤。

// 1. 加载驱动
Class.forName("oracle.jdbc.driver.OracleDriver");
// 2. 创建连接
Connection con = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521:orcl", "scott", "tiger");
// 3. 创建语句
PreparedStatement ps = con.prepareStatement("select * from emp where sal between ? and ? ");
ps.setint(1, 1000);
ps.setint(2, 3000);
// 4. 执行语句
ResultSet rs = ps.executeQuery();
// 5. 处理结果
while (rs.next()) {
    System.out.println(rs.getint("empno") + " - " + rs.getString("ename"));
}
// 6. 关闭资源
finally{
    if (con != null) {
        try {
            con.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

提示:关闭外部资源的顺序应该和打开的顺序相反,也就是说先关闭 ResultSet、再关闭 Statement、再关闭 Connection。上面的代码只关闭了 Connection(连接),虽然通常情况下在关闭连接时,连接上创建的语句和打开的游标也会关闭,但不能保证总是如此,因此应该按照刚才说的顺序分别关闭。此外,第一步加载 驱动在 JDBC 4.0 中是可以省略的(自动从类路径中加载驱动),但是建议保留。


44、Statement 和 PreparedStatement 有什么区别?哪个性能更好?

与 Statement 相比:

① PreparedStatement 接口代表预编译的语句,它主要的优势在于可以减少 SQL 的编译错误并增加 SQL 的安全性(减少 SQL 注射攻击的可能性);

② PreparedStatement 中的 SQL 语句是可以带参数的,避免了用字符串连接拼接 SQL 语句的麻烦和不安全;

③ 当批量处理 SQL 或频繁执行相同的查询时,PreparedStatement 有明显的性能上的优势,由于数据库可以将编译优化后的 SQL 语句缓存起来,下次执行相同结构的语句时就会很快(不用再次编译和生成执行计划)。

补充:为了提供对存储过程的调用,JDBC API 中还提供了 CallableStatement 接口。存储过程(Stored Procedure)是数据库中一组为了完成特定功能的 SQL 语句的集合,经编译后存储在数据库中,用户通过指定存储过程的名字并给出参数(如果该存储过程带有参数)来执行它。虽然调用存储过程会在网络开销、安全性、性能上获得很多好处,但是存在如果底层数据库发生迁移时就会有很多麻烦,因为每种数据库的存储过程在书写上存在不少的差别。


45、在进行数据库编程时,连接池有什么作用?

由于创建连接和释放连接都有很大的开销(尤其是数据库服务器不在本地时,每次建立连接都需要进行 TCP 的三次握手,释放连接需要进行 TCP 四次握手,造成的开销是不可忽视的),为了提升系统访问数据库的性能,可以事先创建若干连接置于连接池中,需要时直接从连接池获取,使用结束时归还连接池而不必关闭连接,从而避免频繁创建和释放连接所造成的开销,这是典型的用空间换取时间的策略(浪费了空间存储连接,但节省了创建和释放连接的时间)。池化技术在 Java 开发中是很常见的,在使用线程时创建线程池的道理与此相同。基于 Java 的开源数据库连接池主要有:C3P0、Proxool、DBCP、BoneCP、Druid 等。

补充:在计算机系统中时间和空间是不可调和的矛盾,理解这一点对设计满足性能要求的算法是至关重要的。大型网站性能优化的一个关键就是使用缓存,而缓存跟上面讲的连接池道理非常类似,也是使用空间换时间的策略。可以将热点数据置于缓存中,当用户查询这些数据时可以直接从缓存中得到,这无论如何也快过去数据库中查询。当然,缓存的置换策略等也会对系统性能产生重要影响。


46、什么是 DAO 模式?

DAO(Data Access Object)顾名思义是一个为数据库或其他持久化机制提供了抽象接口的对象,在不暴露底层持久化方案实现细节的前提下提供了各种数据访问操作。在实际的开发中,应该将所有对数据源的访问操作进行抽象化后封装在一个公共 API 中。用程序设计语言来说,就是建立一个接口,接口中定义了此应用程序中将会用到的所有事务方法。在这个应用程序中,当需要和数据源进行交互的时候则使用这个接口,并且编写一个单独的类来实现这个接口,在逻辑上该类对应一个特定的数据存储。DAO 模式实际上包含了两个模式,一是 DataAccessor(数据访问器),二是 Data Object(数据对象),前者要解决如何访问数据的问题,而后者要解决的是如何用对象封装数据。


47、事务的 ACID 是指什么?

  • 原子性(Atomic):事务中各项操作,要么全做要么全不做,任何一项操作的失败都会导致整个事务的失败;
  • 一致性(Consistent):事务结束后系统状态是一致的。在 MySQL 中,一致性主要由 MySQL 的日志机制处理,它记录数据库的所有变化,为事务恢复提供跟踪记录。如果系统在事务处理中间发生错误,MySQL 恢复过程将使用这些日志发现事务是否已经完全成功执行或需要返回。一致性属性保证数据库从不返回一个未处理的事务。
  • 隔离性(Isolated):并发执行的事务彼此无法看到对方的中间状态;
  • 持久性(Durable):事务完成后所做的改动都会被持久化,即使发生灾难性的失败。通过日志和同步备份可以在故障发生后重建数据。

补充:关于事务,首先需要知道的是,只有存在并发数据访问时才需要事务。当多个事务访问同一数据时,可能会存在 5 类问题,包括 3 类数据读取问题(脏读、不可重复读和幻读)和 2 类数据更新问题(第 1 类丢失更新和第 2 类丢失更新)。

  • 脏读(Dirty Read):A 事务读取 B 事务尚未提交的数据并在此基础上操作,而 B 事务执行回滚,那么 A 读取到的数据就是脏数据。
时间转账事务 A取款事务 B
T1 开始事务
T2开始事务
T3 查询账户余额为 1000 元
T4 取出 500 元余额修改为 500 元
T5查询账户余额为 500 元(脏读)
T6 撤销事务余额恢复为 1000 元
T7汇入 100 元把余额修改为 600 元
T8提交事务
  • 不可重复读(Unrepeatable Read):事务 A 读取了事务 B 已经提交的更改(或删除)数据。比如事务 A 第一次读取数据,然后事务 B 更改该数据并提交,事务 A 再次读取数据,两次读取的数据不一样。
时间转账事务 A取款事务 B
T1 开始事务
T2开始事务
T3 查询账户余额为 1000 元
T4查询账户余额为 1000 元
T5 取出 100 元修改余额为 900 元
T6 提交事务
T7查询账户余额为 900 元(不可重复读)
  • 幻读(Phantom Read):事务 A 读取了事务 B 已经提交的新增数据。注意和不可重复读的区别,这里是新增,不可重复读是更改(或删除)。这两种情况对策是不一样的,对于不可重复读,只需要采取行级锁防止该记录数据被更改或删除,然而对于幻读必须加表级锁,防止在这个表中新增一条数据。
时间统计金额事务 A转账事务 B
T1 开始事务
T2开始事务
T3统计总存款为 10000 元
T4 新增一个存款账户存入 100 元
T5 提交事务
T6再次统计总存款为 10100 元(幻读)
  • 第 1 类丢失更新:事务 A 撤销时,把已提交的事务 B 的数据覆盖掉。
时间取款事务 A转账事务 B
T1开始事务
T2 开始事务
T3查询账户余额为 1000 元
T4 查询账户余额为 1000 元
T5 汇入 100 元修改余额为 1100 元
T6 提交事务
T7取出 100 元将余额修改为 900 元
T8撤销事务
T9余额恢复为 1000 元(丢失更新)

第 2 类丢失更新:事务 A 提交时,把已提交的事务 B 的数据覆盖掉。

时间转账事务 A取款事务 B
T1 开始事务
T2开始事务
T3 查询账户余额为 1000 元
T4查询账户余额为 1000 元
T5 取出 100 元将余额修改为 900 元
T6 提交事务
T7汇入 100 元将余额修改为 1100 元
T8提交事务
T9查询账户余额为 1100 元(丢失更新)

数据并发访问所产生的问题,在有些场景下可能是允许的,但是有些场景下可能就是致命的,数据库通常会通过锁机制来解决数据并发访问问题,按锁定对象不同可以分为 表级锁行级锁;按并发事务锁定关系可以分为 共享锁独占锁。直接使用锁是非常麻烦的,为此数据库为用户提供了自动锁机制,只要用户指定会话的事务隔离级别,数据库就会通过分析 SQL 语句然后为事务访问的资源加上合适的锁。此外,数据库还会维护这些锁通过各种手段提高系统的性能,这些对用户来说都是透明的。ANSI/ISOSQL 92 标准定义了 4 个等级的事务隔离级别,如下表所示:

隔离级别脏读不可重复读幻读第 1 类丢失更新第 2 类丢失更新
读未提交(Read Uncommited)允许允许允许不允许允许
读已提交(Read Committed)不允许允许允许不允许允许
可重复读(Repeatable Read)不允许不允许允许不允许不允许
可序列化(Serializable)不允许不允许不允许不允许不允许

需要说明的是,事务隔离级别和数据访问的并发性是对立的,事务隔离级别越高并发性就越差。所以要根据具体的应用来确定合适的事务隔离级别,这个地方没有万能的原则。


48、JDBC 中如何进行事务处理?

Connection 提供了事务处理的方法,通过调用 setAutoCommit(false) 可以设置手动提交事务;

当事务完成后用 commit() 显式提交事务;

如果在事务处理过程中发生异常则通过 rollback() 进行事务回滚。

除此之外,从 JDBC 3.0 中还引入了 Savepoint(保存点)的概念,允许通过代码设置保存点并让事务回滚到指定的保存点。


49、Java 中是如何支持正则表达式操作的?

正则表达式及其用途:在编写处理字符串的程序时,经常会有查找符合某些复杂规则的字符串的需要。正则表达式就是用于描述这些规则的工具。换句话说,正则表达式就是记录文本规则的代码。

说明:计算机诞生初期处理的信息几乎都是数值,但是时过境迁,今天我们使用计算机处理的信息更多的时候不是数值而是字符串,正则表达式就是在进行字符串匹配和处理的时候最为强大的工具,绝大多数语言都提供了对正则表达式的支持。

Java 中的 String 类提供了支持正则表达式操作的方法,包括:matches()、 replaceAll()、replaceFirst()、split()。此外,Java 中可以用 Pattern 类表示正则表达式对象,它提供了丰富的 API 进行各种正则表达式操作。

面试题: 如果要从字符串中截取第一个英文左括号之前的字符串,例如:北京市(朝阳区)(西城区)(海淀区),截取结果为:北京市,那么正则表达式怎么写?

import java.util.regex.Matcher;
import java.util.regex.Pattern;

class RegExpTest {
    public static void main(String[] args) {
        String str = "北京市(朝阳区)(西城区)(海淀区)";
        Pattern p = Pattern.compile(".*?(?=\\()");
        Matcher m = p.matcher(str);
        if (m.find()) {
            System.out.println(m.group());
        }
    }
}

前面的 .*? 是非贪婪匹配的意思,表示找到最小的就可以了,(?=\\() 就是匹配正括号。


50、用 Java 写一个单例类:

饿汉式单例

public class Singleton {
    private Singleton() {
    }

    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }
}

懒汉式单例

public class Singleton {
    private static Singleton instance = null;

    private Singleton() {
    }

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

注意:实现一个单例有两点注意事项:① 将构造器私有,不允许外界通过构造器创建对象;② 通过公开的静态方法向外界返回类的唯一实例。


51、用 Java 写一个冒泡排序:

import java.util.Comparator;

/**
 * 排序器接口(策略模式: 将算法封装到具有共同接口的独立的类中使得它们可以相互替换)
 *
 */
public interface Sorter {
    /**
     * 排序
     *
     * @param list 待排序的数组
     */
    public <T extends Comparable<T>> void sort(T[] list);

    /**
     * 排序
     *
     * @param list 待排序的数组
     * @param comp 比较两个对象的比较器
     */
    public <T> void sort(T[] list, Comparator<T> comp);
}
import java.util.Comparator;

/**
 * 冒泡排序
 *
 */
public class BubbleSorter implements Sorter {
    @Override
    public <T extends Comparable<T>> void sort(T[] list) {
        Boolean swapped = true;
        for (int i = 1, len = list.length; i < len && swapped; ++i) {
            swapped = false;
            for (int j = 0; j < len - i; ++j) {
                if (list[j].compareTo(list[j + 1]) > 0) {
                    T temp = list[j];
                    list[j] = list[j + 1];
                    list[j + 1] = temp;
                    swapped = true;
                }
            }
        }
    }

    @Override
    public <T> void sort(T[] list, Comparator<T> comp) {
        Boolean swapped = true;
        for (int i = 1, len = list.length; i < len && swapped; ++i) {
            swapped = false;
            for (int j = 0; j < len - i; ++j) {
                if (comp.compare(list[j], list[j + 1]) > 0) {
                    T temp = list[j];
                    list[j] = list[j + 1];
                    list[j + 1] = temp;
                    swapped = true;
                }
            }
        }
    }
}

测试用例:

import java.util.Comparator;

public class Test {
    public static class CompareInteger implements Comparator<Integer>{
        @Override
        public int compare(Integer o1, Integer o2) {
            return o2- o1;
        }
    }
    public static void main(String[] args) {
        Integer[] a= new Integer[]{6, 4, 8, 5, 9, 1, 3, 2};
        BubbleSorter b= new BubbleSorter();
//        b.sort(a);

        b.sort(a, new CompareInteger());
        for (Integer s : a) {
            System.out.println(s);
        }
    }
}

52、用 Java 写一个折半查找:

折半查找,也称二分查找、二分搜索,是一种在有序数组中查找某一特定元素的搜索算法。搜素过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜素过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组已经为空,则表示找不到指定的元素。这种搜索算法每一次比较都使搜索范围缩小一半,其时间复杂度是 O(log N)。

import java.util.Comparator;

public class MyUtil {
    public static <T extends Comparable<T>> int binarySearch(T[] x, T key) {
        return binarySearch(x, 0, x.length - 1, key);
    }

    // 使用循环实现的二分查找
    public static <T> int binarySearch(T[] x, T key, Comparator<T> comp) {
        int low = 0;
        int high = x.length - 1;
        while (low <= high) {
            int mid = (low + high) >>> 1;
            int cmp = comp.compare(x[mid], key);
            if (cmp < 0) {
                low = mid + 1;
            } else if (cmp > 0) {
                high = mid - 1;
            } else {
                return mid;
            }
        }
        return -1;
    }

    // 使用递归实现的二分查找
    private static <T extends Comparable<T>> int binarySearch(T[] x, int low, int high, T key) {
        if (low <= high) {
            int mid = low + ((high - low) >> 1);
            if (key.compareTo(x[mid]) == 0) {
                return mid;
            } else if (key.compareTo(x[mid]) < 0) {
                return binarySearch(x, low, mid - 1, key);
            } else {
                return binarySearch(x, mid + 1, high, key);
            }
        }
        return -1;
    }
}

测试用例:

import java.util.Comparator;

class Test {
    public static class CompareInteger implements Comparator<Integer>{
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1- o2;
        }
    }
    public static void main(String[] args) {
        Integer[] a= new Integer[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        int result1= MyUtil.binarySearch(a, 5, new CompareInteger());

        int result2= MyUtil.binarySearch(a, 3);

        System.out.println(result1+ " "+ result2);
    }
}

注意:上面的代码中给出了折半查找的两个版本,一个用递归实现,一个用循环实现。需要注意的是计算中间位置时不应该使用 (low+ high)/ 2 的方式,因为加法运算可能导致整数越界,这里应该使用以下三种方式之一:

  • low+ (high- low)/ 2
  • low+ (high- low)>> 1
  • (low+ high)>>> 1 (>>> 是逻辑右移, 是不带符号位的右移)

53、Java 中能创建 volatile 数组吗?

能。

Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。

如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用了。


54、volatile 能使得一个非原子操作变成原子操作吗?

一个典型的例子是在类中有一个 long 类型的成员变量。如果该成员变量会被多个线程访问,如计数器、价格等,最好是将其设置为 volatile。因为 Java 中读取 long 类型变量不是原子的,需要分成两步,如果一个线程正在修改该 long 变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个 volatile 型的 long 或 double 变量的读写是原子。


55、volatile 修饰符有过什么实践?

一种实践是用 volatile 修饰 long 和 double 变量,使其能按原子类型来读写。double 和 long 都是 64 位宽,因此对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中 volatile 型的 long 或 double 变量的读写是原子的。volatile 修饰符的另一个作用是提供内存屏障(memory barrier),例如在分布式框架中的应用。简单的说,就是在写一个 volatile 变量之前,Java 内存模型会插入一个写屏障(writebarrier),读一个 volatile 变量之前,会插入一个读屏障(read barrier)。意思就是说,在写一个 volatile 域时,能保证任何线程都能看到写的值,同时,在写之前,也能保证任何数值的更新对所有线程是可见的,因为内存屏障会将其他所有写的值更新到缓存。


56、volatile 类型变量提供什么保证?

volatile 变量提供顺序和可见性保证,例如,JVM 或者 JIT 为了获得更好的性能会对语句重排序,但是 volatile 类型变量即使在没有同步块的情况下赋值也不会与其他语句重排序。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。某些情况下,volatile 还能提供原子性,如读 64 位数据类型,像 long 和 double 都不是原子的,但 volatile 类型的 double 和 long 就是原子的。


57、10 个线程和 2 个线程的同步代码,哪个更容易写?

从写代码的角度来说,两者的复杂度是相同的,因为同步代码与线程数量是相互独立的。但是同步策略的选择依赖于线程的数量,因为越多的线程意味着更大的竞争,所以你需要利用同步技术,如锁分离,这要求更复杂的代码和专业知识。


58、你是如何调用 wait() 方法的?使用 if 块还是循环?为什么?

wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。下面是一段标准的使用 wait 和 notify 方法的代码:

// The standard idiom for using the wait method
synchronized (obj) {
    while (condition does not hold)
    obj.wait(); // (Releases lock, and reacquires on wakeup)
    ...  // Perform action appropriate to condition
}

59、Swing 是线程安全的?

不是。

Swing 不是线程安全的,不能通过任何线程来更新 Swing 组件,如 JTable、JList 或 Jpanel。

它们只能通过 GUI 或 AWT 线程来更新。

这就是为什么 Swing 提供 invokeAndWait() 和 invokeLater() 方法来获取其他线程的 GUI 更新请求。这些方法将更新请求放入 AWT 的线程队列中,可以一直等待,也可以通过异步更新直接返回结果。


60、什么是线程局部变量?

线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java 提供 ThreadLocal 类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。


61、Java 中将 bytes 转换为 long 类型:

Java 代码实现:

/**
  * 将字节数组转为 long<br>
  * 如果 input 为 null,或 offset 指定的剩余数组长度不足 8 字节则抛出异常
  *
  * @param input
  * @param offset       起始偏移量
  * @param littleEndian 输入数组是否小端模式
  * @return
*/
public static long longFrom8Bytes(byte[] input, int offset, Boolean littleEndian) {
    long value = 0;
    // 循环读取每个字节通过移位运算完成 long 的 8 个字节拼装
    for (int count = 0; count < 8; ++count) {
        int shift = (littleEndian ? count : (7 - count)) << 3;
        value |= ((long) 0xff << shift) & ((long) input[offset + count] << shift);
    }
    return value;
}

借助 java.nio.ByteBuffer 实现:

/**
  * 利用 {@link java.nio.ByteBuffer}实现 byte[]转 long
  *
  * @param input
  * @param offset
  * @param littleEndian 输入数组是否小端模式
  * @return
*/
public static long bytesTolong(byte[] input, int offset, Boolean littleEndian) {
    // 将 byte[] 封装为 ByteBuffer
    ByteBuffer buffer = ByteBuffer.wrap(input, offset, 8);
    if (littleEndian) {
        // ByteBuffer.order(ByteOrder) 方法指定字节序,即大小端模式 (BIG_ENDIAN / LITTLE_ENDIAN)
        // ByteBuffer 默认为大端(BIG_ENDIAN)模式
        buffer.order(ByteOrder.LITTLE_ENDIAN);
    }
    return buffer.getLong();
}

62、a= a+ b 与 a+= b 的区别:

+= 隐式的将加操作的结果类型强制转换为持有结果的类型。

如果这两个类型相加,如 byte、short 或者 int,首先会将它们提升到 int 类型,然后再执行加法操作。

byte a= 127; byte b= 127;
b= a+ b;  // error: cannot convert from int to byte
b+= a;    // ok

无论 a+ b 的值为多少,编译器都会报错,因为 a+ b 操作会将 a、b 提升为 int 类型,所以将 int 类型赋值给 byte 就会编译出错。


63、"a== b" 和 "a.equals(b)" 有什么区别?

如果 a 和 b 都是对象,则 a== b 是比较两个对象的引用,只有当 a 和 b 指向的是堆中的同一个对象才会返回 true。

而 a.equals(b) 是进行逻辑比较,所以通常需要重写该方法来提供逻辑一致性的比较。例如,String 类重写 equals() 方法,所以可以用于两个不同对象,但是包含的字母相同的比较。


64、写一段代码在遍历 ArrayList 时移除一个元素:

ArrayList<String> arrayList= new ArrayList<>();
arrayList.add("a");
arrayList.add("ab");
arrayList.add("abc");
arrayList.add("aa");
arrayList.add("abc");
arrayList.add("ad");
arrayList.add("aa");
arrayList.add("abc");
// a
Iterator<String> iterator= arrayList.iterator();
while (iterator.hasNext()){
    if(iterator.next().equals("abc")){
        iterator.remove();
    }
}
// enda
// b 上述代码块 a 可如下表示:
arrayList.removeIf(s -> s.equals("abc"));
// endb
System.out.println(arrayList.toString());

65、Java 中,编写多线程程序的时候会遵循哪些最佳实践?

  • 给线程命名,这样可以帮助调试。
  • 最小化同步的范围,而不是将整个方法同步,只对关键部分做同步。
  • 如果可以,更偏向于使用 volatile 而不是 synchronized。
  • 使用更高层次的并发工具,而不是使用 wait() 和 notify() 来实现线程间通信,如 BlockingQueue,CountDownLatch 及 Semeaphore。
  • 优先使用并发集合,而不是对集合进行同步。并发集合提供更好的可扩展性。

66、在 Java 中守护线程和本地线程的区别?

Java 中的线程分为两种:守护线程(Daemon)和用户线程(User)。任何线程都可以设置为守护线程和用户线程,通过方法 Thread.setDaemon(boolon);true 则把该线程设置为守护线程,反之则为用户线程。Thread.setDaemon() 必须在 Thread.start() 之前调用,否则运行时会抛出异常。两者的区别:唯一的区别是判断虚拟机(JVM)何时离开,Daemon 是为其他线程提供服务,如果全部的 User Thread 已经撤离,Daemon 没有可服务的线程,JVM 撤离。也可以理解为守护线程是 JVM 自动创建的线程(但不一定),用户线程是程序创建的线程;比如 JVM 的垃圾回收线程是一个守护线程,当所有线程已经撤离,不再产生垃圾,守护线程自然就没事可干了,当垃圾回收线程是 Java 虚拟机上仅剩 的线程时,Java 虚拟机会自动离开。

扩展:Thread Dump 打印出来的线程信息,含有 daemon 字样的线程即为守护进程,可能会有:服务守护进程、编译守护进程、windows 下的监听 Ctrl+ break 的守护进程、Finalizer 守护进程、引用处理守护进程、GC 守护进程。


67、死锁与活锁的区别,死锁与饥饿的区别?

死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

产生死锁的必要条件:

  • 互斥条件:所谓互斥就是进程在某一时间内独占资源。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。

活锁和死锁的区别在于:处于活锁的实体是在不断的改变状态,所谓的 “活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。

Java 中导致饥饿的原因:

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。

Java 中用到的线程调度算法是什么?

采用时间片轮转的方式。可以设置线程的优先级,会映射到下层的系统上面的优先级上,如非特别需要,尽量不要用,防止线程饥饿。


68、多线程同步和互斥有几种实现方法,都是什么?

线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。

线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。

线程间的同步方法大体可分为两类:用户模式内核模式。顾名思义,内核模式就是指利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态,而用户模式就是不需要切换到内核态,只在用户态完成操作。

用户模式下的方法有:原子操作(例如一个单一的全局变量),临界区。

内核模式下的方法有:事件,信号量,互斥量。


69、为什么使用 Executor 框架比使用应用创建和管理线程好?

  • 每次执行任务创建线程 new Thread() 比较消耗性能,创建一个线程是比较耗时、耗资源的。
  • 调用 new Thread() 创建的线程缺乏管理,被称为野线程,而且可以无限制的创建,线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪,还有线程之间的频繁交替也会消耗很多系统资源。
  • 直接使用 new Thread() 启动的线程不利于扩展,比如定时执行、定期执行、定时定期执行、线程中断等都不便实现。

使用 Executor 线程池框架的优点:

  • 能复用已存在并空闲的线程从而减少线程对象的创建从而减少了消亡线程的开销。
  • 可有效控制最大并发线程数,提高系统资源使用率,同时避免过多资源竞争。
  • 框架中已经有定时、定期、单线程、并发数控制等功能。

综上所述使用线程池框架 Executor 能更好的管理线程、提供系统资源使用率。


70、乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。

乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

乐观锁的实现方式:

  • 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
  • java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B,否则处理器不做任何操作。

CAS 缺点:

  • ABA 问题
    • 比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
  • 循环时间长开销大
    • 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
  • 只能保证一个共享变量的原子操作
    • 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。

71、SynchronizedMap 和 ConcurrentHashMap 有什么区别?

SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访问 map。 ConcurrentHashMap 中则是一次锁住一个桶。使用分段锁来保证在多线程下的性能。默认将 hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。

另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据 ,iterator 完成后再将头指针替换为新的数据,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。


72、CopyOnWriteArrayList 可以用于什么应用场景?

CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出 ConcurrentModificationException。在 CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。

1、由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc;

2、不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然 CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。

CopyOnWriteArrayList 透露的思想:

  • 读写分离,读和写分开
  • 最终一致性
  • 使用另外开辟空间的思路,来解决并发冲突

73、为什么线程通信的方法 wait(), notify() 和 notifyAll() 被定义在 Object 类里?

Java 的每个对象中都有一个锁(monitor,也可以成为监视器)并且 wait(), notify() 等方法用于等待对象的锁或者通知其他线程对象的监视器可用。在 Java 的线程中并没有可供任何对象使用的锁和同步器。

这就是为什么这些方法是 Object 类的一部分。

这样 Java 的每一个类都有用于线程间通信的基本方法。


74、为什么 Thread 类的 sleep() 和 yield ()方法是静态的?

Thread 类的 sleep() 和 yield() 方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。

这就是为什么这些方法是静态的。

它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。


更新时间:2021-08-12 17:35:56

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×