Java基础知识点

发布时间 2023-06-28 18:29:28作者: xfcoding

面向对象三大特征

  1. 封装

    • 对外隐藏复杂的实现,暴露出简单的使用方法
    • 可以隔离变化,内部的变化外部不知道
    • 提高代码重用性
    • 保护数据
  2. 继承

    • 提高代码重用性(如果仅仅是为了重用,则优先考虑组合)

    • 多态的前提

  3. 多态

    • 前提:继承

    • 作用:提高代码的扩展性

    • 体现:向上转型

    • 限制:向上转型时,子类独有的成员无法使用

继承和实现都体现了传递性。定义如下:

继承:如果多个类的某个部分的功能相同,那么可以抽象出一个类出来,把他们的相同部分都放到父类里,让他们都继承这个类。

实现:如果多个类处理的目标是一样的,但是处理的方法方式不同,那么就定义一个接口,也就是一个标准,让他们都实现这个接口,各自具体实现自己的处理方法来处理那个目标

其中多态有三个必要条件:有类的继承或接口实现、子类重写父类的方法、父类引用指向子类的对象。另外,还有一种说法,多态还分为动态多态和静态多态,前面提到的编译期和运行期的变化属于动态多态,还有一种静态多态,认为 Java中方法的重载是一种静态多态,因为需要在编译期决定具体调用哪个方法。我认为多态应该体现的是一种运行期特性,比如方法的重写算是多态的一种体现,所以我更偏向于重载不是多态

Java 的继承与组合

继承(Inheritance)是一种联结类与类的层次模型,继承是一种is-a关系。组合(Composition)体现的是整体与部分、拥有的关系,即has-a的关系。

如何选择?

建议在同样可行的情况下,优先使用组合而不是继承。
因为组合更安全,更简单,更灵活,更高效。

注意,并不是说继承就一点用都没有了,前面说的是【在同样可行的情况下】。有一些场景还是需要使用继承的,或者是更适合使用继承。

继承要慎用,其使用场合仅限于你确信使用该技术有效的情况。一个判断方法是,问一问自己是否需要从新类向父类进行向上转型。如果是必须的,则继承是必要的。反之则应该好好考虑是否需要继承。《Java编程思想

只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在is-a关系的时候,类B才应该继承类A。《Effective Java

为什么说Java中只有值传递

很多资料上都说:”Java的参数传递机制只有值传递,没有引用传递。“值传递就是在方法调用时,形参接收的值只是实参的一个副本,这个参数在内部发生变化不会对原始实参产生影响。

对于基本类型的参数而言,参数传递是值传递是没有疑问的,有疑问的是引用参数的传递,就是为什么我一个对象传到一个方法里面,方法结束后原来对象的属性就变了,这是否是有违Java值传递的机制?

引用类型参数(如对象)也按值传递给方法。这意味着,当方法返回时,传入的引用仍然引用与以前相同的对象。但是,如果对象字段具有适当的访问级别,则可以在方法中更改这些字段的值。《The Java™ Tutorials》

也就是说,Java会将对象的地址的拷贝传递给被调函数的形式参数,这依然是值传递。

但是怎么理解在对象参数的传递过程,方法对原来对象产生了影响呢?

这就好比:你有一把钥匙,你复制了一把钥匙给你的朋友,你朋友拿这把钥匙去你家把你家东西给拿走了。在这个过程中,对你手里的钥匙来说,是没有影响的,但是你钥匙对应的房子里的东西却被人改变了。

也就是说,Java对象的传递,是通过复制的方式把引用关系传递了,如果我们没有改引用关系,而是找到引用的地址,把里面的内容改了,是会对调用方有影响的,因为大家指向的是同一个共享对象。

如果说,我们在方法内,再次new一个对象,用引用类型的方法入参接收这个对象,那么在方法内不管对这个新的对象做什么,都不会影响方法外的那个实参对象,因为此时方法体内的那个形参指向的是另一个地址。

这就好比:你把你的钥匙复制一份给你的朋友,但你的朋友把这个钥匙给改了,改成了他家自己家的钥匙,这个时候,他对自己家的房子做什么操作都不影响你的房子。

所以,Java中的对象传递,如果是修改引用,是不会对原来的对象有任何影响的,但是如果直接修改共享对象的属性的值,是会对原来的对象有影响的。

Java基本数据类型注意事项

1.Java为什么设计基本数据类型?

首先基本数据类型使用频繁,如果像创建对象那样频繁去new一个基本类型数据,就会很笨重,比较耗费资源。基本数据类型的变量不会在堆内存中创建而是直接存储在栈内存中,因此使用起来更加高效。

2.那为什么又要有包装类呢?

毫无疑问,Java是一种面向对象的编程语言,很多地方都需要用到对象而不是基本数据类型。比如集合中就无法将基本数据类型放进去的,因为集合的泛型要求是Object类型。为了让基本数据类型具有对象的特征,就有了包装类型,使得它具有对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

3.自动装箱与拆箱的实现原理

通过反编译可发现,自动装箱使用的是包装类型的valueOf()方法比如(Integer.valueOf(1)),自动拆箱使用的包装类型的xxxVaue方法(比如var.intValue())。

4.阿里巴巴Java开发手册关于三目运算符空指针风险问题

三目运算符中,如果问号后面的条件表达式一个是基本类型,一个是包装类型,就会触发自动拆箱,这时候,如果包装类型变量为空,就会产生NPE。比如:

 boolean flag = true;
 Integer i = 0;
 int j = 1;
 int k = flag ? i : j;

如果其中的inull,就会报空指针。

5.自动拆装箱带来的问题

自动拆装箱是一个很好的功能,大大节省了开发人员的精力,不再需要关心到底什么时候需要拆装箱。但是,他也会引入一些问题

包装对象的数值比较,不能简单的使用 ==,虽然 -128 到 127 之间的数字可以,但是这个范围之外还是需要使用 equals 比较。

由于自动拆箱,如果包装类对象为 null ,那么自动拆箱时就有可能抛出 NPE。

如果一个 for 循环中有大量拆装箱操作,会浪费很多资源

String

我们都知道,String是Java中一个不可变的类,所以他一旦被实例化就无法被修改。

不可变类的实例一旦创建,其成员变量的值就不能被修改。这样设计有很多好处,比如可以缓存hashcode、使用更加便利以及更加安全等。

既然字符串是不可变的,那么字符串拼接又是怎么回事呢?

其实,所有的所谓字符串拼接,都是重新生成了一个新的字符串。即使拼接了一个新的字符串,也是引用变量指向了一个新的堆内存区域,原来的字符串对象依旧在堆中没有变化。

使用+拼接字符串的实现原理?

根据反编译可以看到,Java中的+对字符串的拼接,其实现原理是使用StringBuilder.append,而StringBuilder最后的toString方法也是新new了一个字符串对象。

字符串常量池

在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的。会单独开辟一块内存,用于保存字符串常量,这个内存区域被叫做字符串常量池。

当代码中出现双引号形式(字面量)创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。

这种机制,就是字符串驻留或池化。

intern()

Stringintern()方法,在调用时,JVM会去字符串常量池检测是否已存在该字符串,如果存在则直接返回该引用;否则在常量池中添加新的并返回引用。

Java中各种关键字

transient

Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。这里的对象存储是指,Java的serialization提供的一种持久化对象实例的机制。当一个对象被序列化的时候,transient修饰的变量的值不包括在序列化的表示中,然而非transient修饰的变量是被包括进去的。使用情况是:当持久化对象时,可能有一个特殊的对象数据成员,我们不想用serialization机制来保存它。为了在一个特定对象的一个域上关闭serialization,可以在这个域前加上关键字transient。

简单点说,就是被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。

反射

反射是Java语言的一个特性,它允许程序在运行时(注意不是编译的时候)来进行自我检查并且对内部的成员进行操作。例如它允许一个Java类获取它所有的成员变量和方法并且显示出来。

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

简而言之,将类的各个组成部分(成员变量、构造器、成员方法等)封装成对象,就是反射机制。

反射机制的作用

1、在运行时判断任意一个对象所属的类;

2、在运行时获取类的对 象;

3、在运行时访问Java对象的属性,方法,构造方法等。

什么要用反射机制

这就涉及到了动态与静态的概念。

静态编译:在编译时确定类型,绑定对象,即通过。

动态编译:运行时确定类型,绑定对象。动态编译最大限度发挥了Java的灵活性,体现了多态的应用,有助降低类之间的藕合性。

反射机制的优缺点

优点:可以实现动态创建对象和编译,体现出很大的灵活性,通过反射机制我们可以获得类的各种内容,进行反编译。对于JAVA这种先编译再运行的语言来说,反射机制可以使代码更加灵活,更加容易实现面向对象。

缺点:对性能有影响。使用反射基本上是一种解释操作,我们可以告诉JVM,我们希望做什么并且让它满足我们的要求。这类操作总是慢于直接执行相同的操作。

反射原理

Java在编译后会生成一个.class字节码文件,反射是通过字节码文件找到其类的成员变量、方法、构造器、接口等。最关键的就是字节码对象Class

获取Class对象的三种方式:

  1. Class.forName("全限定类名"),多用于读取配置文件中的类名,如数据库驱动
  2. 类名.class,多用于参数的传递,如工厂模式
  3. 对象.getClass(),调用的是ObjectgetClass()方法,多用于对象获取字节码对象

同一个字节码文件(.class)在一个程序运行过程中,只会被加载一次,上面的三种方式获取的Class对象都是同一个。

反射应用场景

典型的像动态代理,Spring配置文件的初始化。

序列化与反序列化

序列化是将对象转换为可传输格式的过程。 是一种数据的持久化手段。一般广泛应用于网络传输与RMI(远程方法调用)等场景中。序列化是将对象的状态信息转换为可存储或传输的形式的过程。一般是以字节码或XML格式传输。而字节码或XML编码格式可以还原为完全相等的对象。这个相反的过程称为反序列化。

泛型

什么是类型擦除?

类型擦除可以简单的理解为将泛型Java代码转换为普通Java代码。

类型擦除的主要过程如下: 1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。

上下界限定符extends和super

例子:

public class Food {}
public class Fruit extends Food {}
public class Apple extends Fruit {}
public class Banana extends Fruit{}

public class GenericTest {

    public void testExtends(List<? extends Fruit> list){

        //list.add 报错,extends为上界通配符,只能取值,不能存放
        //因为Fruit的子类不只有Apple还有Banana,这里不能确定具体的泛型到底是Apple还是             
        //Banana,所以放入任何一种类型都会报错
        
        //list.add(new Apple());

        //可以正常获取
        Fruit fruit = list.get(1);
    }

    public void testSuper(List<? super Fruit> list){

        //super为下界通配符,可以存放元素,但是也只能存放当前类或者子类的实例,
        //以当前的例子来讲,无法确定Fruit的父类是否只有Food一个(Object是超级父类)
        //因此放入Food的实例编译不通过
        list.add(new Apple());
        //list.add(new Food());

        Object object = list.get(1);
    }
}

在使用泛型时,添加元素时用super,获取元素时,用extends。

频繁往外读取内容的,适合用上界Extends。经常往里插入的,适合用下界Super。

原始类型 List 和 Object 类型 List<Object> 之间的区别?

原始类型List和带参数类型List<Object>之间的主要区别是,在编译时编译器不会对原始类型进行类型安全检查,却会对带参数的类型进行检查。通过使用Object作为类型,可以告知编译器该方法可以接受任何类型的对象,比如String或Integer。它们之间的第二点区别是,你可以把任何带参数的类型传递给原始类型List,但却不能把List<String>传递给接受 List<Object>的方法,因为会产生编译错误。

List<?>和List<Object>之间的区别?

List<?> 是一个未知类型的List,而List<Object> 其实是已知的但是任意类型的List。你可以把List<String>, List<Integer>赋值给List<?>,却不能把List<String>赋值给 List<Object>

I/O流

按流的流向来分:

  • 输入流:只能从中读取数据,不能向其写入数据;
  • 输出流:只能向其写入数据,不能从中读取数据。

输入、输出都是从程序运行所在内存的角度来划分的,比如,输入从硬盘到内存,称为输入流;数据从内存到硬盘,通常称为输出流。

使用原则

通常来讲,如果进行输入输出的是文本内容,就考虑用字符流;如果输入输出的内容是二进制内容,则考虑用字节流。计算机里的文件可以分为文本文件和二进制文件。虽然计算机里的文件本质上都是二进制文件,但当二进制文件里的内容能被正常解析为字符时,可以称之为文本文件。

输入/输出流的四大基类

  • 字节流:InputStream/OutputSteam
  • 字符流:Reader/Writer

计算机打开文件的时候,都会查询编码表,把底层的二进制编码转为人们熟知的字符,比如,0-127的数字会查询ACSII表,其他值查询系统默认编码表(中文就是GBK)。

InputStream/OutputSteam

FileOutputStream的常用API:

write(byte b[]); // 这个调的也是下面这个方法
write(byte b[], int off, int len) // b[]是要写入的数组,off是b[]的开始索引,len是写入的长度

FileInputStream常用 API:

int read(); // 一次读取一个字节,返回值为读取到的字节数据

// 一次读取b[]数组长度的字节,调的也是第三个的方法,数组索引从0开始,长度为b.length
// 返回值为读到缓冲区数组里的字节数量,或者-1(结束)
// 每次读完,缓冲区数组中就有了读到的字节数组
int read(byte b[]);
int read(byte b[], int off, int len);

那么,常见的应用场景就是把一个文件拷贝到另一个地方。那么步骤就是先读,再写。

// 把a文件内容拷贝到b文件
FileInputStream fis = new FileInputStream("D:\\a.txt");
FileOutputStream fos = new FileOutputStream("D:\\b.txt");
byte[] bytes = new byte[1024]; // 字节缓冲区,一般长度定义为1024或其倍数
int readLength = 0; // FileInputStream的read方法,返回值为读入缓冲区的总字节数
while ((readLength = fis.read(bytes)) != -1) {
    fos.write(bytes, 0, readLength); // 读了多少字节,就写多少字节
}
fos.close();
fis.close();

注意FileOutputStreamwrite((byte b[]))方法,它再次调用write(byte b[], int off, int len)方法,len传的缓冲区数组的长度,如果,b[]的实际内容少于它的长度,那么写入另一个文件后就会在实际内容后跟很多NULL值,这是不必要的,所以应该要用上面的写法:

fos.write(bytes, 0, readLength); // 读了多少字节,就写多少字节

Reader/Writer

用字节流读取文本文件时,会有一个问题,如果遇到中文字符是,可能不会显示完整的字符,那是因为一个中文字符可能占用多个字节存储(在 GBK 字符集下,一个中文 = 2 个字节,UTF-8 下,一个中文 = 3 个字节)。这个时候,最好用字符流来处理。

字符输出流的使用步骤跟字节输出流类似,不同点是,字符输出流的write方法,是把数据写入到内存缓冲区中(就是字符转为字节的过程),最后需要手动调用flush方法或者close方法,把数据刷新到文件中。

缓冲流

缓冲流的创建都是基于基本的流对象的

  • 字节缓冲流:BufferedInputStreamBufferedOutputStream
  • 字符缓冲流:BufferedReaderBufferedWriter

二进制流:ByteArrayStream,也很重要

字节流和字符流的区别:字符流读写会查码表,字节流不会。

flush 和 close 方法的区别

  • flush:刷新缓冲区,流对象可以继续使用;
  • close:先刷新缓冲区,然后通知系统释放资源。流对象不能再被使用了。

理解了字节流的读写过程后,字符流的也差不多。既然是字符流,肯定可以直接写charchar[]String类型的数据了。

递归

递归的分类:直接递归,间接递归

  • 直接递归:在方法中调用自身。
  • 间接递归:A 方法调 B 方法,B 方法调 A 方法。

递归注意事项:

  1. 递归一定要有条件限定,保证递归能停止,否则会发生栈内存溢出;
  2. 在递归中虽然有限定条件,但递归次数依然不能太多,否则也会发生栈内存溢出。
  3. 构造方法,禁止递归。

深拷贝、浅拷贝

通常来讲,浅拷贝是指对于一个对象有引用类型的成员变量时,使用Objectclone方法对这个对象拷贝,属于浅拷贝。因为引用变量其实指向一个内存地址,浅拷贝把这个引用变量拷贝后,新的引用变量跟原来的引用变量指向的内存地址相同。这个时候问题就来了,如果对一个对象中的该引用变量进行修改,另一个对象的该引用变量也会随之修改,因为两个引用变量指向了同一个内存地址。而深拷贝则是给拷贝后的该引用变量开辟了一个新的内存空间,也就是该引用变量与拷贝前的引用变量的内存地址不一样了。 这样的话,修改不会影响到彼此。

Objectclone方法不会调用对象的构造器,而是在内存中对原对象数据的拷贝。

面向对象思想

  1. 你对面向对象思想的理解?

    对象通常对应现实世界中事务的模型,定义一个对象包含至少两个部分:属性、行为。一个系统被看成是一些对象的集合;而开发一个系统,我们要确认系统中存在的对象,由此确定有哪些类(Class)。这些对象通过相互协作来完成程序任务。

  2. 面向对象和面向过程的区别在哪里?

    在面向过程或结构化的程序中,通常情况下属性和行为是分开的。在面向对象程序中,属性和行为都包含在某一个对象中。也就是说,在面向过程的程序中,基本构成是函数;面向对象程序中,基本构成是对象。

  3. 举例说明面向对象思想?

JVM

知识点

  1. class文件结构
  2. classloader
  3. jvm运行时数据区
  4. 垃圾回收器和垃圾回收算法
  5. JIT

类加载机制

JVM把.class文件加载到内存中时,创建对应的class对象,这个过程称之为类的加载机制。

类的加载过程

Loading -> Linking -> Initializing

加载、连接(验证、准备、解析)、初始化。

  • 加载:查找文件。通过类的全限定名
  • 初始化:为类的静态变量赋值,然后执行类的初始化语句(static代码块)。

初始化过程:

  • 类的初始化是在类的加载和链接完成之后开始,这是固定顺序;
  • 如果存在父类,且父类没有初始化,则先初始化直接父类;
  • 如果类中存在初始化语句,顺序执行初始化语句。

类的初始化的时机:包括主动引用和被动引用。

  • 创建类的实例(四种方式:new、反射、反序列化、克隆)
  • 访问类中的某个静态变量,或者对静态变量进行赋值
  • 调用类的静态方法
  • 反射Class.forName
  • 子类的初始化,会先完成父类的初始化(接口除外)
  • 主动执行main方法

类初始化过程总结

  1. 对类的主动引用:new一个对象、调用类变量、类方法;反射;父类还未初始化时;执行主类(main)。

  2. 被动引用:

    (1)子类引用父类静态字段,不会导致子类初始化。

    class ClassLoadingTest {
        public static void main(String[] args) {
            System.out.println(SubClass.value);
        }
    }
    
    class SuperClass {
        static {
            System.out.println("Superclass init");
        }
        public static int value = 123;
    }
    
    class SubClass extends SuperClass {
        static {
            System.out.println("Subclass init");
        }
    }
    

    结果是:

    Superclass init
    123
    

    可以看到子类并未初始化。

    (2)通过数组定义来引用类,不会导致类的初始化。

    class ClassLoadingTest {
        public static void main(String[] args) {
            SuperClass[] superClasses = new SuperClass[10];
        }
    }
    

    这里,main方法的执行结果是什么都不打印。这种情况是类虽然被加载了,但未初始化。

    (3)常量在编译阶段存入调用类的常量池中,本质上没有直接引用该类,不会出发类的初始化。

    如果给value字段加上final修饰,打印结果为:

    123
    

    也就是说,SuperClass并未初始化。

    类加载器

    类加载是类加载流程的实现者。

    JDK自带的Class loader:

    • BootStrap ClassLoader:自带的引导类加载器。由C/C++语言实现。在Java中打印为null,加载Java的核心类库
    • Extension ClassLoaderLauncher的内部类
    • Application ClassLoaderLauncher的内部类

    问题:为什么需要自定义classLoader?

    • 适配环境隔离(Tomcat中就有自定义类加载器)
    • 从不同的数据源加载类
    • 防止源码泄露

    双亲委派模型

    类的加载时,会向上询问是否加载,同时从上向下尝试是否可加载该类。

    双亲委派模型的作用:

    1. 避免类的重复加载;

    2. 保护程序安全,防止Java核心语言环境被破坏。

    编译器优化和指令重排

    重排序过程:源代码 --> 1编译器优化重排序 --> 2指令级并行重排序 --> 3内存系统重排序 --> 最终执行的指令序列。

    1属于编译器重排序,2,3属于处理器重排序。重排序会导致多线程程序出现内存可见性问题。

    重排序-数据依赖性

    编译器和处理器可能对操作做重排序。编译器和处理器在重排序时,会遵守数据依依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

    注意,这里所说的数据依赖性仅针对于单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

    as-if-serial原则

    就是编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果这些操作不存在数据依赖关系,则可能被编译器和处理器重排序。

参考资料

Java工程师成神之路