Java基础知识

  • 面向对象特征
  • 基本数据类型及装箱拆箱机制
  • String StringBuffer StringBuild
  • final finally finalize 区别
  • static关键字
  • 重写和重载
  • this super 区别
  • 代码块
  • 异常
  • throw throws 区别
  • 内部类
  • 创建对象的五种方式
  • 反射的使用
  • equals hashCode == 区别
  • 线程池的使用
  • 序列化和反序列化
  • 运行时注解
  • 编译时注解
  • 动态代理和静态代理
  • volatile
  • 抽象类和接口区别
  • Runnable和Callable的区别
  • Java中的HashMap的工作原理是什么?
  • HashMap和Hashtable有什么区别?
  • ArrayList和LinkedList不同点:
  • List遍历
  • Map遍历


面向对象特征

主流观点是封装,继承,多态;其实很多人把抽象也归类到面向对象的特征之一

  • 封装:把过程和数据隐藏起来,使得数据的访问只能通过对外公开的接口,保证了对象被访问的安全性
  • 继承:类的一种层次模型,提供了一种明确表述共性的方法,类可以获取并使用基类的属性且可以重写基类方法,同时可以修改基类方法或者增加新的方法使其更加适合现有场景的需求;在Java中只有单继承
  • 多态:指对象在不同时刻表现出来的多种状态,是一种编译期和运行期状态不一致的现象
  • 抽象:对一类事物的高度提炼以得到它们的共性,抽象不需要了解全部细节,而只是一种通用的描述

这里重点讲下多态:

比如有个类Person,它有一个子类Student,那么你在编写代码时会这样写:

Person p = new Student();

在编译期时父类变量p指的是父类Person,但是在运行时p实际指向子类Student,这就是多态;而通过p去调用变量或者方法时,具体调用的是父类的还是子类的有个口诀

  • 成员变量:编译看左,运行看左;因为变量无法重写,即这个p无法调用子类定义的变量
  • 成员方法:编译看左,运行看右;因为子类可以重写父类的方法
  • 静态方法:编译看左,运行看左;因为静态方法属于类

面试题:实现多态有几种方式

具体方式有:重写,接口,继承(抽象类实现多态依靠继承和重写)


基本数据类型及装箱拆箱机制

  • 整数值型:byte,short,int,long
  • 浮点型:float,double
  • 字符型:char
  • 布尔型:boolean

整数默认int型,小数默认是double型。Float和long类型的必须加后缀。
String属于引用类型,不是基本类型

平时说的拆箱和装箱就是引用类型和基本类型的转换,基本类型转换成引用类型后就可以调用其包装类的方法进行类型转换

这里给一个面试题来讲解

Long l1 = 128L;
Long l2 = 128L;
long l3 = 128L;
System.out.print((l1 == l2) + "\n");
System.out.print(l1 == l3);

你们知道这两个结果是true还是false呢?

  • 我们知道Long包装类型常量有一个缓存常量池,范围是-128~127;这样不管程序中定义了多少个Long类型变量,只要值在这个范围且它们数值都是一样,那么它们都是引用的同一个对象;
    所以l1 == l2 是false,因为l1和l2的值超过缓存常量池范围,所以它们指的是两个不同对象,而==比较的是对象地址,显然结果就是false
  • 包装类型在表达式中运算且表达式中至少有一个不是包装类型,那么包装类型就会自动拆箱退化成基本数据类型进行比较,而==在比较基本数据类型时比较的是值,所以l1 == l3的结果就是true

其它的Integer Short Byte同理,都是有缓存常量池,范围是-128~127;而Double和Float则没有缓存,不管是什么值都会new一个对象来表达该数值,因为在指定范围内浮点型数据个数是不确定的

自动装箱拆箱机制是JDK1.5开始的,该机制是在编译时完成的,装箱阶段会自动替换为valueOf方法,拆箱阶段自动替换为xxxValue方法;

  • 比如Long l1 = 127L这句代码,在编译时会自动装箱变成Long l1 = Long.valueOf(127L),Integer Short Byte同理,同样因为缓存池的存在,只要数值在范围内,就会返回已经存在的对象的引用;否则创建新的对象返回;对于Double和Float装箱时每次都是创建新的对象返回
  • 再比如Long l1 = 127L;long l2 = l1;后面这句在编译时会自动拆箱成long l2 = l1.longValue(),这个大家可以反编译class文件就知道编译器帮我们做了什么

String StringBuffer StringBuild

String是字符常量,StringBuffer StringBuild都是字符串变量;前者创建的内容不可变,而后两者创建的内容可变;String的每次操作都是在内存中重新new一个对象,而StringBuffer StringBuild不需要,而且它们提供了一定的缓存能力,默认16个字节大小的数组,超过默认的数组长度后会扩容为原来长度的两倍再加2,所以在使用它俩时可以考虑指定长度避免频繁扩容带来的性能问题

StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的;StringBuilder并没有对方法进行加同步锁,所以是非线程安全的;String中的对象是不可变的,也就可以理解为常量,显然线程安全。如果程序不是多线程的,那么使用StringBuilder效率高于StringBuffer,在大部分情况下StringBuilder> StringBuffer > String

面试题:为什么说String是不可变的呢?

String不可变是因为在JDK中String类被声明为final类,且内部的字节数组也是final的;只有当字符串不可变时字符串池才可能实现,字符串池的存在可以节约很多heap空间,因为不同的字符串变量都指向池中的同一个字符串;这种不可变机制可以避免网络安全问题,多线程并发安全问题,数据库的用户名、密码都是以字符串的形式传入;socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。

而StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,没有用final修饰符,所以这两种对象都是可变的

因为字符串的不可变性,所以在创建时它的hashCode就被缓存了,所以不变性保证了哈希码的唯一性,不需要重新计算,这就使得String很适合作为Map的key,因为Map取值的时间很大一部分消耗在哈希码的计算上,所以使用字符串一定程度上能提高Map的使用效率

final finally finalize 区别

其实这三个关键字之间没有什么联系,仅仅只是长的像而已;在面试时被问到也只是考察开发者有没有区分这几个关键字的含义

final 是一个修饰符

  • 修饰类:该类不能被继承,所以一个类不能同时被final和abstract修饰
  • 修饰属性:该属性一旦赋值就不能被更改,即此时该属性为常量
  • 修饰方法:该方法可以被调用,不能被子类重写

finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常,即使finally之前有return,finally块中代码都会执行

finalize是一个方法名, 是在对象被垃圾回收器删除它之前调用的方法,给对象自己最后一个复活的机会,但是什么时候调用 finalize 没有保证

面试题:匿名内部类引用方法局部变量时为什么需要声明为final

首先我们来研究一下变量的生命周期的问题,局部变量的生命周期是当该方法被调用时,该方法中的局部变量在栈中被创建,当方法调用结束时(执行完毕)出栈,这些局部变量就会回收。但是内部类对象是创建在堆中的,其生命周期跟其它类一样,只有当jvm用可达性分析法发现这个对象通过GCRoots节点已经不可达,然后进行gc才会死亡。所以完全有可能存在的一个现象就是一个方法已经调用结束(局部变量已死亡),但该内部类的对象仍然活着,也就是说内部类维护的局部变量的引用还存在,那么当内部类调用局部变量时就会出错,出现非法引用的问题

再谈谈数据同步的问题,内部类不是直接引用局部变量,而是通过构造方法的参数将其拷贝到内部,那么一个基本数据类型如果没有加final修饰且修改了这些参数,并不会对局部变量产生影响,仅仅改变内部类中备份的参数。但是在外部调用时发现值并没有被修改,这种问题就会很尴尬,造成数据不同步

所以Java要求所有能被匿名内部类访问的变量都用final修饰

面试题:JVM对声明为final得局部变量有优化处理吗?

大家看开源框架代码得时候,应该经常能看到在某个方法内部,作者在定义局部变量时使用final修饰,那为什么这么做呢?是因为这样会使得JVM访问效率变高吗?我们来看看

第一种情况:当局部变量在编译期不能确定值得情况下,使用final修饰不会产生什么实际效果

int foo() {
  int a = someValueA();
  int b = someValueB();
  return a + b; // 这里访问局部变量
}
int foo() {
  final int a = someValueA();
  final int b = someValueB();
  return a + b; // 这里访问局部变量
}

上面得代码和下面得代码唯一不同的是:下面的变量使用了final修饰,但是当你j通过avac编译得到的字节码却是一样的

invokestatic someValueA:()I
istore_0 // 设置a的值
invokestatic someValueB:()I
istore_1 // 设置b的值
iload_0  // 读取a的值
iload_1  // 读取b的值
iadd
ireturn

字节码里没有任何东西能体现出局部变量的final与否,Class文件里除字节码(Code属性)外的辅助数据结构也没有记录任何体现final的信息。既然带不带final的局部变量在编译到Class文件后都一样了,其访问效率必然一样高,JVM不可能有办法知道什么局部变量原本是用final修饰来声明的

第二种情况:当局部变量在编译期可以确定值,即是编译时常量,这时使用final修饰效率会高些,看代码

int foo2() {
  final int a = 2; // 声明常量a
  final int b = 3; // 声明常量b
  return a + b;    // 常量表达式
}

这里的a和b都不是变量,而是编译时常量,在Java语言规范里称为constant variable,其访问会按照Java语言对常量表达式的规定而做常量折叠,实际上跟下面的等同

int foo3() {
  return 5;
}

由javac编译得到对应的字节码会是:

iconst_5 // 常量折叠了,没有“访问局部变量”
ireturn

这种情况如果去掉final修饰,那么a和b就会被看作普通的局部变量而不是常量表达式,在字节码层面上的效果会不一样

int foo4() {
  int a = 2;
  int b = 3;
  return a + b;
}

被编译成

iconst_2
istore_0 // 设置a的值
iconst_3
istore_1 // 设置b的值
iload_0  // 读取a的值
iload_1  // 读取b的值
iadd
ireturn

这样就比上面的代码要执行更多的字节码指令

但其实这种层面上的差异只对比较简易的JVM影响较大,因为这样的VM对解释器的依赖较大,原本Class文件里的字节码是怎样的它就怎么执行;对高性能的JVM(例如HotSpot、J9等)则没啥影响。这种程度的差异在经过好的JIT编译器处理后又会被消除掉,上例中无论是 foo3() 还是 foo4() 经过JIT编译都一样能被折叠为常量5。
Android里的Dalvik VM虽然是个比较简单的VM,但Android开发套件里的dexopt也可以用来处理这种final的局部“常量”与“变量”的差异,所以实际性能也不会受多少影响。
还有,先把成员或静态变量读到局部变量里保持一定程度的一致性,例如:在同一个方法里连续两次访问静态变量A.x可能会得到不一样的值,因为可能会有并发读写;但如果先有final int x = A.x然后连续两次访问局部变量x的话,那读到的值肯定会是一样的。这种做法的好处通常在有数据竞态但略微不同步没什么问题的场景下,例如说有损计数器之类的。
最后,其实很多人用这种写法的时候根本就没想那么多吧。多半就是为了把代码写短一点,为了把一串很长的名字弄成一个短一点的而把成员或静态变量读到局部变量里,顺便为了避免自己手滑在后面改写了局部变量里最初读到的值而加上final来让编译器(javac之类)检查。例如:

final int threshold = MySuperLongClass.someImportantThreshold;

这里引用知乎大神 RednaxelaFX 的回答、


static关键字

  • static方法就是没有this的方法。在static方法内部不能调用非静态方法,因为非静态方法是在对象创建的时候初始化的,静态方法在类加载的时候就初始化了;反过来是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法。static方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而static方法是编译时静态绑定的
  • static变量也称作静态变量,静态变量和非静态变量的区别是:静态变量被所有的对象所共享,在内存中只有一个副本,它当且仅当在类初次加载时会被初始化。而非静态变量是对象所拥有的,在创建对象的时候被初始化,存在多个副本,各个对象拥有的副本互不影响。
  • static成员变量的初始化顺序按照定义的顺序进行初始化
  • static代码块也叫静态代码块,通常是把只进行一次的初始化操作放在代码块中以优化程序性能,避免重复开辟内存。static块可以置于类中的任何地方,类中可以有多个static块。在类初次被加载的时候,会按照static块的顺序来执行每个static块,并且只会执行一次
  • static类,static可以用来修饰内部类,也就是静态内部类;可以避免非静态内部类持有外部类引用导致内存泄漏的问题

这里给一个面试题来讲解

public class Test {
    
    public static String foo(){
        System.out.print("call foo");
        return "you called";
    }
    
    public static void main(String[] args){
        
        Test test = null;
        System.out.print(test.foo());
    }
    
}

你认为最后是什么结果?是报NPE还是正确打印呢?

结果是正常打印,不会报异常

因为JVM运行时数据区(内存)分为两部分,线程私有和线程共享两块区域;线程私有区分为程序计数器,本地方法栈,虚拟机栈,而线程共享区分为堆(heap)和方法区,堆主要用来存放new出来的对象,而方法区存放常量,静态变量,静态方法,类的信息等;这样即使对象没有被创建出来,但是类加载的时候类信息和静态方法已经存在方法区了,所以运行时JVM识别出test调用的是方法区的静态方法,也就不会抛出异常了


重写和重载

  • 重写是作用于父类的方法。重载是作用于自己的方法
  • 方法重写要求参数列表必须一致,而方法重载要求参数列表必须不一致
  • 方法重写要求返回类型必须一致(或为其子类型),方法重载对此没有要求
  • 方法重写只能用于子类重写父类的方法,方法重载用于同一个类中的所有方法
  • 方法重写对方法的访问权限和抛出的异常有特殊的要求,而方法重载在这方面没有任何限制
  • 父类的一个方法只能被子类重写一次,而一个方法可以在所有的类中可以被重载多次
  • 重载是编译时多态,因为编译器可以根据参数的类型来选择使用哪个方法;重写是运行时多态,因为编译期编译器不知道并且没办法确定该去调用哪个方法,JVM会在代码运行的时候作出决定

面试题:父类的构造方法能不能被重写?

我们假设能重写构造方法,但是我们知道类的构造方法必须与类名一致,所以这里就产生矛盾了,故构造方法不能被重写

但是构造方法是可以被重载的


this super 区别

this:作为当前类的引用,谁调用代表谁
super:是父类存储空间标识,可以理解为父类对象引用,调用父类方法,变量

this使用场景:

  • 构造方法:通过this调用另一个重载的构造方法,用法是this(参数);该语句必须在构造方法体第一行,非构造方法不能这么使用;如下
public Person() {
        this("1");
    }

    public Person(String age) {
        this.age = age;
    }
  • 变量:当方法参数或者方法内局部变量与成员变量同名的情况下,此时成员变量会被屏蔽;要想访问成员变量必须使用this.成员变量名来访问;如下
public Person(String age) {
        this.age = age;
    }
  • 方法:在方法中可以通过this来表示该类的引用,从而调用该类的属性方法等

注意:切记this不能在static方法中使用,因为static方法是类级别,this是对象级别的

super使用场景:

  • 构造方法:在子类构造方法中需要调用父类构造方法,可以使用super(参数)的语句,参数可不传,但是这句代码必须在方法块第一行
  • 变量:当子类方法中的局部变量或者成员变量与父类的成员变量重名时,可以使用super.变量名来引用父类成员变量
  • 方法:当子类重写父类方法时,可以使用super(参数)来调用父类方法

注意:切记super也不能在static方法中使用

面试题:this和super能不能在同一个构造方法中共存?

我们先假设能共存,然后慢慢分析:

super方法放在第一行是因为子类有可能访问父类对象,比如在构造方法中使用了父类的构造方法,在成员变量初始化时使用了父类的变量,在方法中使用了父类方法等;所以放在第一行可以保证子类在访问父类对象之前完成对父类对象的初始化,以免报错;同时开发者如果没有手动加上super方法和this方法,那Java会默认在第一行补上super()方法

this方法放在构造方法第一行是为了保证父类对象初始化的唯一性,怎么理解这句话呢?假设B类是A类的子类,B类的构造方法中this方法放在了除第一行的任意一行,同时第一行添加了super方法,这样super方法会第一个执行,将父类对象初始化;当执行到this方法的时候,会调用B类另一个重载的构造方法,这个方法又会调用super方法去实例化父类,这样就造成父类对象的多次实例化

所以在构造方法中this方法和super方法都要求在第一行的情况下,它们两不能同时存在


代码块

代码块就是用大括号{}将任意行代码包起来形成一个独立的数据体;一般来说,代码块不能单独执行,需要有运行主体

代码块分为以下几种:

  • 普通代码块:是在方法名后面用{}括起来的代码,不能单独存在,必须要紧跟方法名且用方法名调用该代码块;作用是限定变量的声明周期和提高效率
  • 构造代码块:在类中用{}括起来的代码,作用是把所有构造方法中相同部分提取出来,在执行构造方法前会自动执行构造代码块
  • 静态代码块:在类中用{}括起来的代码,并且前面有static修饰符,在类被加载的时候会被执行,且只执行一次
  • 同步代码块:在方法中用synchronized关键字修饰且用{}包括起来的代码,是一种多线程并发保护机制

面试题:静态代码块、构造代码块、构造方法的执行顺序

因为静态代码块是作用于类级别的,构造代码块和构造方法作用于对象级别,所以当类被加载的时候,静态代码块就会执行;而构造代码块和构造方法在每次创建对象的时候被执行,且构造代码块是提取所有构造方法的相同部分,所以构造代码块比构造方法先执行;所以三者执行顺序是:静态代码块>构造代码块>构造方法


异常

在Java中,阻止当前方法或作用域的情况,称之为异常

在Java中所有表示异常的最终父类是Throwable,它有两个子类Error,Exception

  • Error:包括虚拟机错误(VirtualMachineError)和线程死锁(ThreadDeath),一旦error出现,程序就挂掉了
  • Exception:我们通常所说的异常指的是它,指编码,运行环境,用户操作等出现问题,它又分为两大类,检查异常和非检查异常

这里里面非检查异常指的是RuntimeException,也就是运行时异常,比如NullPointerException,ClassCastException,ArrayIndexOutOfBoundsException,ArithmeticException等;该异常由虚拟机在运行时抛出,此类异常出现的原因基本上是开发者代码质量导致的

而检查异常就是在代码编写时就会抛出的异常,开发者要么try/catch,要么使用throws抛出;主要是IOException和SQLException


throw throws 区别

  • throw是在方法内使用,后面跟异常实例,用于抛出一个异常;如果后面接的异常是RuntimeException及其子类,那么方法声明时就不需要使用throws抛异常;如果后面接的是Exception及其子类,则方法声明上必须有throws
public void init(){
        throw new NullPointerException();
    }
    public void init() throws Exception {
        throw new Exception();
    }
  • throws是在方法声明后面,后面跟一个或者多个异常类型,用逗号分隔;如果后面接的是RuntimeException及其子类,那么方法调用者不用处理该异常;如果是Exception及其子类,那么调用者要么try/catch,要么继续使用throws抛出
public void init() throws NullPointerException{

    }
    public void call(){
        init();
    }
    public void init() throws Exception{

    }
    public void call(){
        try {
            init();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

内部类

在Java中,内部类分为静态内部类,匿名内部类,成员内部类,局部内部类

  • 静态内部类:定义在一个类里面且用static修饰的类,静态内部类是不需要依赖于外部类,并且它不能使用外部类的非static成员变量或者方法,这点很好理解,因为在没有外部类的对象的情况下,可以创建静态内部类的对象,如果允许访问外部类的非static成员就会产生矛盾,因为外部类的非static成员必须依附于具体的对象
public class Outer {


    static class Inner{
        
    }
}
  • 匿名内部类:就是一个没有显式的名字的内部类,所以没办法引用它们,必须在创建时,作为new语句的一部分来声明它们;匿名内部类会隐式的继承一个类或者实现一个接口,它是一种没有构造器的类,大部分匿名内部类用于接口回调
public void main(){
        //使用匿名内部类作为参数
        childSleep(new Sleep() {
            @Override
            public void sleep() {

            }
        });

        /**
         * 通过接口创建匿名内部类赋值给s
         * 相当于定义了该接口的实现类s,并重写接口方法
         */
        Sleep s = new Sleep() {
            @Override
            public void sleep() {

            }
        };

        /**
         * 通过实体类创建匿名内部类赋值给p
         * 相当于创建该类的一个子类对象p
         */
        Person p = new Person(){
            @Override
            public void eat() {
                super.eat();
            }
        };

        /**
         * 通过抽象类创建匿名内部类赋值给a
         * 相当于定义了该抽象类的一个子类对象a
         */
        Animal a = new Animal() {
            @Override
            public void bark() {

            }
        };
    }

    void childSleep(Sleep sleep){

    }
    //抽象类
    abstract class Animal{
        public abstract void bark();
    }

    //实体类
    class Person{
        public void eat(){
            System.out.println("吃饭");
        }
    }
    //接口
    interface Sleep{
        void sleep();
    }
  • 成员内部类:定义在类里面且没有用static修饰的类,是最普通的一个类,可以看做是外部类的成员;可以无条件访问外部类的成员变量和成员方法,即使用private修饰,其实成员内部类内部是持有额外部类的引用;外部类不能直接访问内部类的属性和方法,需要先创建内部类的对象然后使用这个对象的引用来访问
public class Outer {

    class Inner{
        
    }
}
  • 局部内部类:定义在方法内部,代码块内部的类,就相当于方法里面的一个局部变量,所以类前面不能有任何修饰符,也不能在方法外部对其实例化使用
public class Outer {

    public void init(){
        class PartInner{

        }
    }
    
}

面试题:为什么成员内部类可以无条件访问外部类的成员

我们通过下面的例子说明:

public class Outer {
    
    class Inner{
        
    }

}

将其进行编译会产生两个class文件,Outer.class和Outer$Inner.class,可以看到Java会将内部类单独编译成一个class文件,接着再反编译这个内部类的class文件,看看它的字节码:

class com.zte.isp.Lock.Outer$Inner {
  final com.zte.isp.Lock.Outer this$0;

  com.zte.isp.Lock.Outer$Inner(com.zte.isp.Lock.Outer);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #1                  // Field this$0:Lcom/zte/isp/Lock/Outer;
       5: aload_0
       6: invokespecial #2                  // Method java/lang/Object."<init>":()V
       9: return
}

可以看到Java帮开发者给这个内部类添加了一个指向外部类对象的引用和构造方法(参数是外部类对象,并且将参数赋值给那个引用),所以在成员内部类中可以随意方位外部类的成员


创建对象的五种方式

Java提供了五种创建对象的方式,分别是:使用new关键字、使用Class类的newInstance、使用Constructor类的newInstance方法、使用Clone方法、使用反序列化;接下来一一介绍

先定义一个Bean

public class Person {

    private String age;
    private int id;

    public Person(String age, int id) {
        this.age = age;
        this.id = id;
    }

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

    private Person() {

    }

}
  • 使用new关键字创建对象可以说是平时开发中使用的最多的一种了,通过这种方法可以调用目标对象的任意构造方法,太简单了就不用代码演示了
  • 使用Class类的newInstance创建对象,用到的是反射技术,它只能调用目标对象的无参构造方法创建对象,如下
Person p = Person.class.newInstance();
Class.forName("com.mango.init.Person").newInstance();

其实Class的newInstance()内部也是使用的Constructor类的newInstance方法

  • 使用Constructor类的newInstance方法和上面的有点像,可以通过newInstance方法调用有参的构造方法,甚至是private修饰的构造方法
Constructor<Person> con =  Person.class.getConstructor(String.class,int.class);
 Person p = con.newInstance("15",2);

 Constructor<Person> c = Person.class.getDeclaredConstructor();
 c.setAccessible(true);
 Person pp = c.newInstance();
  • 使用clone方法创建对象,JVM会将被克隆的对象的数据全部拷贝到新的对象中,该方法不会调用任何构造方法,只是在Heap中新开辟一块内存,将数据写进去;但是前提是得给Bean实现Cloneable接口并实现clone方法
Person p = new Person("15");
System.out.print(p.hashCode()+"\n");
try {
    Person pp = (Person) p.clone();
    System.out.print(pp.hashCode());
} catch (CloneNotSupportedException e) {
    e.printStackTrace();
}

可以通过两者的哈希码判断两者是否是同一个对象

  • 使用反序列化一个对象时,JVM会给我们创建一个新的对象,但不会调用任何构造方法;要想反序列化一个对象,需要实现Serializable接口
Person person = new Person("15");
System.out.print(person.hashCode()+"\n");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("data.obj"));
oos.writeObject(person);
oos.close();

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("data.obj"));
Person p = (Person) ois.readObject();
ois.close();
System.out.print(p.hashCode());

可以通过两者的哈希码判断两者是否是同一个对象


反射的使用

可以参考博主的另一篇文章Android开发-带你玩转Android中的反射机制,再也不怕private修饰了


equals hashCode == 区别

在Java中class树的最顶部是Object,也就是所有的类的最终父类是Object

  • 先看下它的hashCode逻辑
public native int hashCode();

该方法的作用是获取对象的哈希码值,将对象的内部地址转换为整数;它遵守以下约定:
1.在同一个Java程序里即使调用多次hashCode方法,返回的值是相同的,前提是对象equals方法比较所用到的信息没有修改
2.如果两个对象通过equals方法判定相等,那两者hashCode返回值一定相等
3.如果两个对象通过equals方法判定不相等,那两者hashCode返回值不一定相等,尽量保证不相同的对象产生截然不同的哈希值可以提高散列表的性能

开发者应该遵循上述原则,否则在某些场合会出现性能问题;比如Set集合新增元素效率下降;使用Hashmap出现数据碰撞的几率大增,造成存取性能急剧下降

  • 再来看看 ==
    == 在Java中是运算符,它在比较基本数据类型时比较的是值是否相等;在比较对象时比较的是两者在JVM内存中的地址是否相等
  • 最后看看equals
    在Object中equals方法源码如下:
public boolean equals(Object obj) {
        return (this == obj);
    }

它内部是使用 == 进行比较的,所以使用equals和使用 == 是一样的效果

注意:

面试题:equals方法和==的区别?

最大的区别是:equals是方法,== 是运算符
等号在比较基本数据类型时比较的是值,而用等号比较两个对象时比较的是两个对象的地址
equals方法比内部也是使用==进行比较,如果类重写了equals方法,那就由重写逻辑决定

面试题:自定义Bean重写equals方法需要重写hashCode方法吗?

这个问题其实是会在实际项目中埋雷的,因为我们经常在定义Bean的时候重写equals方法而没有重写hashCode方法。这样带来的一个问题就是将数据存入HashSet、HashMap、HashTable等基于哈希表的集合时存取性能下降,无法正常运行
所以在重写equals方法同时需要重写hashCode方法,是为了遵循该方法的常规约定,也就是通过equals方法判定相等的对象,那两者hashCode返回值一定要相等

面试题:如何正确重写hashCode方法?

重写该方法要遵循几个原则:

  • 如果重写了equals方法,且equals方法判断两者相等那么hashCode方法结果必须保证一致
  • 重写逻辑不能太简单,否则会产生很多哈希冲突
  • 重写逻辑也不能太复杂,以免造成高复杂度计算影响性能

这里推荐<< Effective Java >>这本书中给出的一种基于17和31散列码思想的算法:

public class Person implements Serializable {

    private String name;
    private String location;
    private int age;

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (!(obj instanceof Person)) return false;
        Person p = (Person) obj;
        return p.name.equals(name)
                && p.location.equals(location)
                && p.age == age;
    }

    /**
     * 实现正确的哈希逻辑
     * @return
     */
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        result = 31 * result + location.hashCode();
        result = 31 * result + age;
        return result;
    }
}

这里也可以使用JDK1.7开始提供的java.util.Objects来重写这两个方法

@Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (!(obj instanceof Person)) return false;
        Person p = (Person) obj;
        return Objects.equals(p.name,name)
                && Objects.equals(p.location,location)
                && p.age == age;
    }

    /**
     * 实现正确的哈希逻辑
     * @return
     */
    @Override
    public int hashCode() {
        return Objects.hash(name,location,age);
    }

hashCode方法使用的属性要根据equals使用到的属性而定


线程池的使用

可以参考博主的另一篇文章Android开发-通过ExecutorService构建一个APP使用的全局线程池


序列化和反序列化

可以参考博主的另一篇文章Android开发-序列化和反序列化的实现 Serializable Parcelable

运行时注解

可以参考博主的另一篇文章Android使用运行时注解+反射仿写EventBus组件通信框架 掌握事件总线通信核心原理

编译时注解

可以参考博主的另一篇文章Android使用编译时注解+注解处理器APT生成Java代码 仿写ButterKnife框架核心功能


动态代理和静态代理

可以参考博主的另一篇文章Android开发如何理解静态代理Java动态代理及动态生成代理对象原理 看这篇就够了

volatile

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

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

该关键字可参考volatile


抽象类和接口区别

接口是对动作的抽象,抽象类是对根源的抽象

  1. 抽象类和接口都不能直接实例化,如果要实例化,抽象类变量必须指向实现所有抽象方法的子类对象,接口变量必须指向实现所有接口方法的类对象
  2. 抽象类要被子类继承,接口要被类实现
  3. 接口只能做方法申明,抽象类中可以做方法申明,也可以做方法实现
  4. 接口里定义的变量只能是公共的静态的常量,抽象类中的变量是普通变量
  5. 抽象类里的抽象方法必须全部被子类所实现,如果子类不能全部实现父类抽象方法,那么该子类只能是抽象类。同样,一个实现接口的类,如不能全部实现接口方法,那么该类也只能为抽象类
  6. 抽象方法只能申明,不能实现,接口是设计的结果 ,抽象类是重构的结果
  7. 抽象类里可以没有抽象方法
  8. 如果一个类里有抽象方法,那么这个类只能是抽象类
  9. 抽象方法要被实现,所以不能是静态的,也不能是私有的
  10. 接口可继承接口,并可多继承接口,但类只能单继承

Runnable和Callable的区别

Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
这其实是很有用的一个特性,因为多线程相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以方便获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务。


Java中的HashMap的工作原理是什么?

Java中的HashMap是以键值对(key-value)的形式存储元素的。HashMap需要一个hash函数,它使用hashCode()和equals()方法来从集合添加和检索元素。当调用put()方法的时候,HashMap会计算key的hash值,然后把键值对存储在集合中合适的索引上。如果key已经存在了,value会被更新成新值。

详细原理可参考源码解析-深刻理解Hash HashTable HashMap原理及数据hash碰撞问题


HashMap和Hashtable有什么区别?

HashMap和Hashtable都实现了Map接口,因此很多特性非常相似。但是,他们有以下不同点:
HashMap允许键和值是null,而Hashtable不允许键或者值是null。
Hashtable是同步的,而HashMap不是。因此,HashMap更适合于单线程环境,而Hashtable适合于多线程环境。


ArrayList和LinkedList不同点:

ArrayList是基于索引的数据接口,它的底层是数组。它可以以O(1)时间复杂度对元素进行随机访问。
与此对应,LinkedList是以元素列表的形式存储它的数据,每一个元素都和它的前一个和后一个元素链接在一起,
在这种情况下,查找某个元素的时间复杂度是O(n)。
b相对于ArrayList,LinkedList的插入,添加,删除操作速度更快,因为当元素被添加到集合任意位置的时候,
不需要像数组那样重新计算大小或者是更新索引。
LinkedList比ArrayList更占内存,因为LinkedList为每一个节点存储了两个引用,一个指向前一个元素,一个指向下一个元素。
大O符号描述了当数据结构里面的元素增加的时候,算法的规模或者是性能在最坏的场景下有多么好。
大O符号也可用来描述其他的行为,比如:内存消耗。因为集合类实际上是数据结构,我们一般使用大O符号基于时间,
内存和性能来选择最好的实现。大O符号可以对大量数据的性能给出一个很好的说明。


List遍历

第一种:
 for(object o :list)
 {
 }第二种:
 Iterator iter = list.iterator();
 while(iter.hasNext()){
 Object o = iter.next();
 }第三种:
 int size = list.size();
 for(int i=0; i< size; i++){
 Object o= list.get(i);
 }

在数据量达到百万级甚至千万级的时候,第三种是最快的,第一种和第二种差不多,第二种稍微好点;数据量小的时候没有明显差别

Map遍历

第一种:
 //遍历key 通过key获取value
 for(Integer key:map.keySet()){
 System.out.println(key);
 String value = map.get(key);
 }第二种:
 //遍历value 只能获取value
 for(String value:map.values()){
 System.out.println(value);
 }

第三种:使用的较多

for(Map.Entry< Integer, String> entry : map.entrySet()){
 entry.getKey();
 entry.getValue();
 }

第四种:可以在遍历的时候删除key,其它方式不行

Iterator< Map.Entry< Integer, String>> iterator = map.entrySet().iterator();
 while (iterator.hasNext()) {
 Map.Entry< Integer, String> entry = iterator.next();
 entry.getKey();
 entry.getValue();
 iterator.remove();
 }

同样的在数据量小的时候,第二种和第三种速度差不多,第四种慢
在数据量在百万级和千万级的时候,第四种就很慢了,第三种最快,第二种次之