Java高级知识之JavaSE(下)

多线程

进程就是一个应用程序,就是一个软件,我们每次开启软件就像是打开一个进程,而线程就是一个进程中的执行场景/执行单元,一个进程可以并发多个线程

对于现在的java程序而言每次打开进程时起码有两个线程并发,一个是执行main方法的主线程,另外一个是垃圾回收线程

进程和线程的关系

进程之间的内存是独立不共享的,但是对于同一个进程间的线程而言,其堆内存和方法区内存是共享的,但是其栈内存不共享,一个线程对应一个栈

JVM虚拟机是一个进程

如果我们启动10个线程,那么就会有10个栈空间,每个栈和每个栈之间互不干扰,各自执行,这就是多线程并发,多线程并发有利于提高效率,java中之所以有多线程机制,目的就是为了提高程序的处理效率

一旦我们使用了多线程机制,即使main方法结束了,也有程序可能还在执行的情况,因为main方法结束只是令其主线程结束,然而其他线程可能还在执行

各个线程执行各个线程的,线程之间不会互相影响,这就是多线程并发

多线程并发对于多核CPU电脑而言能够做到,每个核可以简单理解为每个人,每个线程可以理解为事情,如果有多个人的话,自然可以互不干扰的处理多个事情。

但是对于单核的CPU电脑而言做不到,单核CPU电脑利用其切换速度不断地在多个线程之间切换执行给人一种在多线程并发的感觉,但实际上不是的,它只是通过频繁切换线程来达到同时处理多个线程的感觉

分析下下面的程序除了垃圾回收器外存在几个线程

或许有的同学会说,4个,因为这里一共有4个方法,但其实答案是1个。因为我们之前说过线程之间的栈独立,那么我们判断是否有多个线程就可以从栈的数量来判断,在这里,栈只有一个,只是在栈里进行了各种压栈方法而已,所以其实本质还是一个线程,因为只有一个栈

继承Thread

这个方式就是令类直接继承Thread并重写run方法,这样就开启一个线程了,实现多线程,java中支持多线程机制而且已经实现好了,我们只需要继承就可以了

在上图的代码里,我们编写了MyThread类继承Thread多线程并实现了run方法,此时我们在主方法里只要new一个分支线程对象,然后调用该对象的start();方法会在JVM中开辟一个新的栈,这个栈我们称之为支栈,副线程对应的方法会自动在支栈里实现,我们只要调用start();方法开辟了对象就行了。这里run方法会压到支栈底,而main方法会压到主栈底,这里main方法和run方法是平行的

如果我们不调用start方法,而是直接调用创建对象里的run方法,那么我们其实就没有实现多线程机制,因为我们没有开辟新的栈,实际上两个方法都是在主栈中执行的

这里值得一提的是start():方法的主要作用是开辟新的栈空间,只要开辟完毕这个方法就立刻结束了,速度很快,与同时开启主线程的时间几乎相差无几,因此主线程和副线程都可以简单看做是同时执行的,这就是我们所谓的多线程并发

最后一点要提的是在java语言中代码必然是自上而下执行的,因此如果start();方法再快都好,都一定要求执行完start();方法之后才能继续执行其下面的代码

在主方法里我们先调用start方法,然后start方法压入主栈并执行,创造分支栈对象,然后start方法结束,接着我们就有了支栈和主栈,这两个栈会分别独立执行main方法和栈方法,在控制台上会输出不连续的结果,一会是主栈输出的内容一会是支栈输出的内容,这种结果也说明了多线程并发是同时执行的,这里因为控制台只有一个所以打印的结果会是杂乱无序的

实现Runnable接口

第二种方式是编写一个类,实现java.lang.Runnable接口,实现其下的run方法

但是这个类虽然实现了Runnable接口也实现了run方法,但现在这个类还只是一个可运行的类,还不是一个线程。往Thread里传入一个Runnable对象就可以创造一个线程对象

ini 复制代码
My Runnable r = new Runnable();
Thread t = new Thread(r);
t.start();
//合并之后的代码如下
Thread t = new Thread(new MyRunnable());

先创建一个可运行的Runnable对象,然后调用调用Thread的构造方法传入该对象,就可以将可运行的对象封装成一个线程对象,然后照样调用其start方法就可以了

平时采取哪一种方式来实现多线程比较好呢?当然是第二种方式,原因有二。第一个是因为我们java开发推荐面向接口编程,第二个是因为我们采用的接口的方式,以后实现了Runnable接口的类还可以继承其他类,而如果我们采用继承的方式,java中又是不支持多继承的,这样就会给我们的需求带来不便,因此我们推荐用第二种方式

匿名内部类方式

本质还是第二种方式,只不过是采用匿名内部类的方式,直接new一个Runnable类然后实现其必须要实现的方法

线程生命周期

本节可以做对于异步调用为何在控制台中输出的结果不连续的一个解释

首先我们的线程对象刚new出来时处于新建状态,此时我们的线程对象还没有启动,支栈也还没有开辟出来,但是当我们调用start方法之后,就会在JVM中开辟一个支栈,这个支栈会调用其run方法,接着我们的线程就开启了,run方法的开始执行标志着线程进入就绪状态(又被称为可运行状态) ,当我们的线程处于就绪状态时,会具有抢夺CPU时间片的权利(CPU时间片就是执行权,简而言之就是想到了就可以执行方法了)。一旦线程抢夺到了执行权,线程就会从就绪状态进入运行状态,进入运行状态后会接着上一次进入运行状态执行的代码接着执行,如果是第一次进入就从头执行。由于我们拿到的是时间片,因此只能运行一段时间,当运行之后完毕之后其会重新由运行状态回到就绪状态,接着再次进行CPU时间片的抢夺

这里有一种特殊情况,当我们的方法进入运行状态但是突然执行到了等待用户输入一定数据之后才能继续执行的代码之后,我们就称之为遇到阻塞事件,遇到阻塞事件的线程会进入阻塞状态阻塞状态的线程会放弃之前占有的CPU时间片。

当我们处于阻塞状态的线程获得了对应的数据之后,由于在阻塞状态下其放弃了之前占有的时间片,因此其会重新进入就绪状态,继续进行时间片的抢夺

当我们的run方法结束之后,线程就会进入死亡状态,不再运行

经过这些过程的讲解我们就能够更好的理解为什么在控制台里我们得到的数据是不连续的,这是由于两个线程在不断抢夺执行权导致的

线程对象的方法

获取对象名字用getName();方法,修改线程对象名字用setName();方法

Thread.currentThread()方法可以返回对当前正在执行的线程对象引用

如果我们在主方法里创建了两个同一个类的线程对象然后开辟了两个栈空间,那么当这两个线程并行时,谁调用这个方法该方法就返回哪个线程的对象,具体到控制台上就是打印的线程名字不一样

有点类似于this的感觉,但这个方法并不等同于this

Thread.sleep();该方法的作用是令当前线程睡眠一定时间(也可以说是进入阻塞状态一定时间),当前线程是谁其就让当前线程进入睡眠状态,睡眠时长由用户传入的毫秒数决定

sleep方法面试题

来看看下面的代码,试判断主线程和副线程哪个会进入阻塞状态

答案是主线程,虽然这里是通过引用的方式调的sleep方法,但是sleep方法是静态方法,用引用的方式调用和用静态方式调用的效果是一样的,最终该方法发挥作用,会让当前线程进入阻塞状态,因为该方法出现在main主线程的方法中,因此其会令主线程进入阻塞状态

线程对象里的Interrupt();方法可以终止线程的睡眠,其通过异常机制唤醒的,A线程调用该方法唤醒B线程会给B线程抛出异常将其唤醒

合理终止线程的执行

如果我们要强行终止线程的执行,那么我们可以调用线程对象的stop();方法强行终止线程的执行,但是这个方法是过时的,因为强行终止线程的执行容易丢失数据

我们可以给主线程对象定义一个布尔类型的变量,构建一个if...else判断语句,这样我们在主方法里想要终止副线程的执行只要将run赋值为false就行了,这样我们终止线程时,我们需要保存什么数据可以在else的return前去保存,这样就可以避免出现丢失数据的问题了

线程安全

线程一旦满足以下三个条件,就会存在线程不安全的问题

  1. 多线程并发
  2. 有共享数据
  3. 共享数据有修改行为

使用线程同步机制,该机制其实就是线程排队,其本质原理是线程不能并发了,必须排队执行,可以令线程安全

同步和异步

线程同步涉及到同步编程模型异步编程模型,异步编程模型其实就是多并发,同步编程模型其实就是线程排队

同步代码块synchronized

使用关键字synchronized,构造一个同步代码块,让线程进入此代码块中只能排队进入,而不能同时独立进入

括号内传入的对象是我们希望线程进入时需要进行排队的对象

传入的对象必须是多个线程共有的共享对象才能令同步代码块发挥作用,可以扩大同步范围,只需要传入一个范围更大共享对象即可,但是会进一步降低效率

该关键字也可以直接传入到实例方法和静态方法上,但是这样直接把synchronized赋予方法的方式来构造线程安全的代码有缺陷,那就是这样做我们的代码所认定的线程共享的只能是当前类,也就是默认传入了this,不如其他方灵活,其次是这样构造代码其范围是整个实际方法体,而不是我们选中的代码块,可能会扩大线程安全范围,降低效率,实际开发中这个方法不常用

如果说我们共享的对象就是this当前类,而且需要同步的代码块就是整个方法体的,那么我们就建议使用这种方式,使用该方式的优点是,能让代码整体变得简洁

如果synchronized修饰静态方法,那么其加入的锁就是类锁,不管new了几个线程对象,只要调用该类的方法就一定需要等待锁

synchronized原理

在java语言中,任何对象都有一把锁,其实这个锁是标记,只不过我们这里将其称之为锁,对于synchronized关键字所构造的代码块而言,一旦线程进入到了synchronized语句块中,其就会寻找其共享对象的对象锁,找到之后占有这把锁,相当于把对象锁住,不让其他线程进入,等到他完成了事情之后,才会将锁打开,这时其他线程才可以拿到这个锁结束排队进入这个代码块并再次锁上对象然后执行自己的任务

处于运行状态的线程遇到synchronized语句之后户进入锁池寻找共享对象,此时会释放此前占有的CPU时间片,如果找到了对象锁则其会进入就绪状态继续抢夺CPU执行权,没找到就一直等

但如果线程里根本就没有共享对象的话,那么线程并不会进入锁池,会直接进行异步编程,各自独立运行该代码块,因此我们的共享对象的确定在我们构造synchronized代码块里来说非常重要,这能够决定我们那些线程要进行同步编程,而那些线程执行异步编程

当然,我们其实也可以简单理解为线程遇到synchronized语句之后会进入阻塞状态,锁池在这里只是起到帮助理解的作用,实际上并不是一种线程实际处于的状态

哪些变量有线程安全问题

synchronized的面试题

csharp 复制代码
package cn.itcast.algorithm.heap;
​
//面试题:doOther方法执行的时候需要等待doSome方法的结束吗?
//答案:不需要,因为doOther()方法没有synchronized
public class Exam01 {
    public static void main(String[] args) throws InterruptedException {
        MyClass mc = new MyClass();
​
        Thread t1 = new MyThread(mc);
        Thread t2 = new MyThread(mc);
​
        t1.setName("t1");
        t2.setName("t2");
​
        t1.start();
        Thread.sleep(1000);
        t2.start();
    }
}
​
class MyThread extends Thread {
    private MyClass mc;
​
    public MyThread(MyClass mc) {
        this.mc = mc;
    }
    public void run(){
        if(Thread.currentThread().getName().equals("t1")){
            mc.doSome();
        }
        if(Thread.currentThread().getName().equals("t1")){
            mc.doOther();
        }
    }
}
​
class MyClass {
    public synchronized void doSome(){
        System.out.println("doSome begin");
        try {
            Thread.sleep(1000*10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("doSome over");
    }
    public void doOther(){
        System.out.println("doOther begin");
        System.out.println("doOther over");
    }
}

但如果我们给doOther方法也加上synchronized方法的话,那么就需要等待了,虽然说这是两个不同的方法,但是因为这里的synchronized默认的共享对象是this,也就是当前的MyClass类,这样线程遇到该代码块时都必须先找到对应的锁才能继续执行下面的方法,而锁一开始已经被doSome方法给占用了,因此doOther方法必须等待doSome方法的执行结束,结束之后将锁归还才能让doOther方法开始执行

这里怎么理解呢?我个人的理解是线程一旦遇到synchronized语句就要去查找锁且确定synchronized代码块的执行范围和查看共享对象来确定要不要等待,也就是要不要继续找锁,比如在这里t1线程先拿到了锁执行doSome方法,t2线程同样遇到了synchronized语句之后去查找锁,在这个过程中其发现执行的代码块范围不一样,但是共享对象是一致的都是MyClass,因此t2线程确定自己需要这把锁才能继续执行,所以t2线程继续等待

如果以最开始的例子为例的话也是一样的,当共享对象为obj时,第二个线程遇到synchronized语句就回到锁池里去找对应的锁,找的过程中确定要执行的代码块一致,而且共享对象也一致,那就要等待锁了,因此线程选择继续等待第一个线程的执行完毕

当然,上面的都是我推测的理解过程,不保证对

如果我们把代码改成下面这样的话

ini 复制代码
MyClass mc1 = new MyClass();
MyClass mc2 = new MyClass();
​
Thread t1 = new Thread(mc1);
Thread t2 = new Thread(mc2);

这样构造代码的话又不需要等待了,因为这样构造代码实际上两个线程都在执行不同的两个对象,拿到的都是不同的对象锁,而不是同一个对象的,因此不用等待

但如果我们给两个方法就加入static关键词修饰,其他不做修改的话,那么由于synchronized关键字修饰的是静态方法,那么当线程遇见该synchronized语句时,其要执行的就是类锁,无论我们创建了几个对象,其类锁都只有一把,因此此时线程仍然需要等待

死锁概述

所谓死锁也就是当两个线程执行两个共同的对象时,如果两个对象都有synchronized关键词修饰,而且一个线程是先锁住第一个对象,再锁第二个,第二个线程则是反过来的,那么有可能第一个线程先锁住第一个对象之后,第二个线程把第二个对象给先锁了,这时候第一个线程无法锁住第二个对象,因为第二个对象已经被第二个线程锁住了,而且第二个线程也无法锁住第一个对象,因为第一个对象已经被第一个线程给锁住了,此时两个线程会互相等待对方执行完,那就一直等呗,永远也没个头

java 复制代码
package cn.itcast.algorithm.heap;
​
/*
 * 死锁代码要会写
 * 一般面试官要求你会写
 * 只有会写的,才会在以后的开发中注意这个事儿
 * 因为死锁很难调试
 */
public class DeadLock {
    public static void main(String[] args) {
        Object o1 = new Object();
        Object o2 = new Object();
​
        //t1和t2两个线程共享o1,o2
        Thread t1 = new MyThread1(o1,o2);
        Thread t2 = new MyThread1(o1,o2);
​
        t1.start();
        t2.start();
    }
}
​
class MyThread1 extends Thread{
    Object o1;
    Object o2;
    public MyThread1(Object o1,Object o2){
        this.o1 = o1;
        this.o2 = o2;
    }
    public void run(){
        synchronized (o1){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (o2){
​
            }
        }
    }
}
​
class MyThread2 extends Thread {
    Object o1;
    Object o2;
    public MyThread2(Object o1,Object o2){
        this.o1 = o1;
        this.o2 = o2;
    }
    public void run(){
        synchronized (o2){
            try {
                Thread.sleep(1000);
                //这里加入睡眠代码的目的是为了让死锁正确出现,因为如果不加入有可能其中一个线程执行得非常快,全部执行完了才到第二线程
                //此时这个代码的运行状况是正常的,因此我们要加入这个睡眠代码,让死锁代码能够每次运行都正确产生死锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (o1){
​
            }
        }
    }
}

死锁是不会报异常和错误的,也不会有编译时异常,因此死锁是很难调试的,我们在实际的开发中要尽量避免死锁的产生,最简单的想法就是尽可能去避免构建synchronized嵌套使用的代码

如何避免线程不安全问题

守护线程

java语言中的线程分为两大类,一类是用户线程 ,另一类是守护线程。我们之前讲的线程(包括main方法)就属于用户线程,而现在我们要讲守护线程。守护线程就是后台线程,其特点是守护线程是一个死循环,只有当所有的用户线程结束时,守护线程才会自动结束

守护线程最具有代表性的就是垃圾回收线程,只要用户线程不结束,垃圾回收器就一直执行

守护线程用的地方可以是用来数据备份,这里需要用到后面学习的知识定时器,我们可以将定时器设置为守护线程,这样就能让其每到一个固定时间进行一次数据备份

实现守护线程

这样执行代码的话,最终我们在控制台上看到的效果就是主线程运行十次,而副线程一直运行不停止,如果我们想让副线程在我们的所有的线程结束之后自动结束的话,我们只需要将副线程设置为守护线程就可以了

那么如何设置守护线程呢?其实也很简单,只要调用setDaemon();方法并传入true,比如说构造t.setDaemon(true);的代码,就可以将线程t设置为守护线程了,这样即使我们的线程里的代码是个死循环,他也会在所有用户线程结束之后自动结束,因为守护线程的特性

实现Callable接口

实现Callable接口的方式来实现线程,这个方式和之前的两个方式不同的是用这个方式实现线程能够返回线程得到的结果,在其他线程里我们可以选择接受这个结果

java 复制代码
public class HomeWork {
    public static void main(String[] args) throws ParseException, ExecutionException, InterruptedException {
        //此处采用匿名内部类的方式来实现Callable接口
        FutureTask task = new FutureTask(new Callable() {
            @Override
            public Object call() throws Exception {
                //call方法其实就相当于是run方法,不同的是call方法有返回值
                System.out.println("call method begin");//模拟执行
                Thread.sleep(1000*5);
                System.out.println("call method end!");
                int a = 100;
                int b = 200;
                return a+b;
            }
        });
​
        //创建线程对象,创造方式和我们之前的两种方式不太一样,这里先记住吧
        Thread t = new Thread(task);
​
        //启动线程
        t.start();
​
        //现在是在主线程中,如何获取t线程的返回结果?
        Object obj = task.get();
​
        /*
         * 注意主线程执行到此时会进入阻塞状态,需要等待另外一个线程的返回值
         * 只有当其准确拿到返回值之后,才会继续执行下面的代码
         */
        System.out.println("hello world");
    }
}

使用第三种方式的缺陷是会令程序效率降低,因为我们这里会令线程进入阻塞状态,好处是,使用这个方法真的能够拿到线程的返回值,而其他两种方式都拿不到

想要拿到线程的返回值只要在实现了Callable接口的线程里调用其get();方法就可以获得了

wait和notify概述

wait和notify方法并不是线程对象的方法,而是java对象中自带的方法,任何一个java对象都有。而这两个方法的调用方式也不是通过线程对象引用.的方式来调用的,而是通过普通对象引用.的方式来调用的

wait();的作用是让正在o对象上活动的线程进入无限期的等待状态(目前不知道等待状态是不是阻塞状态),直到被唤醒为止。而notify();方法可以将处于等待状态的线程唤醒

其实觉得有点像sleep();方法,不同的是sleep();方法可以不用唤醒自动醒来,而wait();方法不行

还有一点要提及的是wait();方法会让线程进入等待状态的同时还会令线程返还其所占有的对象锁,而notify();方法只会通知,不会释放线程所占有的o对象的锁

生产者和消费者模式

首先,生产者消费者模式是为了专门解决某种特定需求的模式,这种模式是要求一个线程进行生产,另一个线程负责消费,最终我们要达到生产和消费的均衡,而我们生成的对象和要消费的对象自然是存储到仓库中的,仓库中的对象是线程共享的,而由于是线程共享的,所以自然要在synchronized线程同步的基础之上,所以调用对象里的wait方法和notify方法同样要在线程同步的基础之上,要实现生产者和消费者模式,我们就要用到wait方法和notify方法

java 复制代码
package cn.itcast.algorithm.heap;
​
import java.util.ArrayList;
import java.util.List;
​
public class HomeWork {
    public static void main(String[] args) {
        //创建一个共享的仓库对象
        List list = new ArrayList();
        //创建两个线程对象
        //生产者线程
        Thread t1 = new Thread(new Producer(list));
        //消费者线程
        Thread t2 = new Thread(new Consumer(list));
​
        t1.setName("生产者线程!");
        t2.setName("消费者线程!");
​
        t1.start();
        t2.start();
    }
}
​
//生产线程
class Producer implements Runnable {
    //仓库
    private List list;
​
    public Producer(List list) {
        this.list = list;
    }
​
    @Override
    public void run() {
        //一直生产(用死循环来模拟)
        while (true){
            //给仓库对象list加锁
            synchronized (list){
                if(list.size() > 0){
                    //大于0,说明仓库中已有元素,不必再生成
                    try {
                        //生成者线程进入等待,释放list对象锁
                        list.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //程序执行到此说明仓库不为空,可以生产
                Object obj = new Object();
                list.add(obj);
                System.out.println(Thread.currentThread().getName() + "--->"+obj);
                //唤醒生产者生产
                list.notify();
            }
        }
    }
}
​
//消费线程
class Consumer implements Runnable {
    //仓库
    private List list;
​
    public Consumer(List list) {
        this.list = list;
    }
​
    @Override
    public void run() {
        //一直生产(用死循环来模拟)
        while (true){
            //给仓库对象list加锁
            synchronized (list){
                if(list.size() == 0){
                    //等于0说明仓库空了
                    try {
                        //仓库已经空了
                        //消费者线程等待,释放list对象锁
                        list.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //程序执行到此说明仓库中有数据,进行消费
                Object obj = list.remove(0);
                System.out.println(Thread.currentThread().getName() + "--->" + obj);
                //唤醒消费者消费
                list.notify();
            }
        }
    }
}

此处可以唤醒一个线程,也可以两个都唤醒,都一样的,反正另外一个唤醒了也会因为条件不符合而进入等待状态

反射机制概述

在java中,通过反射机制可以操作字节码文件,有点类似于黑客(可以读和修改字节码文件),通过反射机制也可以操作代码片段。

Java中反射机制的相关类在java.lang.reflect.*包下;

与反射机制相关重要的类有四个,分别是

java.lang.Class:代表整个字节码,代表一个类

java.lang.reflect.Method:代表字节码中的方法字节码,也就是类中的方法

java.lang.reflect.Constructor:代表字节码中的构造方法字节码,代表类中的构造方法

java.lang.reflect.field:代表字节码中的属性字节码,代表类中的成员变量(静态变量)

那么整个User类就是java.lang.Class,其中的构造方法就是java.lang.reflect.Constructor,提供的方法则是java.lang.reflect.Method,最后类中的成员变量no则是java.lang.reflect.field

获取字节码文件的三种方式

一旦程序有获得某个字节码文件的需求,JVM会将对应的字节码文件装载到方法区中并保存获取,后续如果有获得字节码文件的需求,会先从方法区中寻找已经存在的字节码文件,类似于字符串常量池

Class.forName()

在java.lang.Class类下我们可以找到这样一个静态方法,该静态方法只要输入想获取的类的完整类型,就可以返回一个Class对象

这里有找不到指定目标异常,所以要进行异常的处理

我们这里要传入的是字符串,其次是我们一定要传入完整类名,否则会报错

getClass()

这个方法可以返回Object时的运行类,比如运行String类时调用该方法可以直接获得String的Class字节码对象

.Class

java语言中任何一种类型都有.class属性,因此我们可以通过类型.class的方式令其直接返回一个对应的字节码文件

通过反射实例化对象

获取了字节码文件之后,可以通过字节码文件来将对象创建出来,先假设我们构建了一个User类,请看代码

我们在测试类里写入了如下代码,在测试类里我们先写入User类的完整路径然后返回其字节码文件,然后调用字节码文件里的c.newInstance();方法将对象创建出来,其实其本质是调用字节码文件里的newInstance();方法会调用该字节码中的无参数构造方法然后将对象创建出来,所以我们这里要保证无参数构造方法在User类中的存在,否则会报错

还有一个小知识,框选类名右键,选择Copy Reference,就可以复制该类的绝对路径了

通过读属性文件实例化对象

那么我们通过上面这样的方式来创建对象有什么用呢?这不是比我们之前直接new对象变得更加复杂了吗?的确是更加复杂了,但是这个方式也更加灵活了。现在让我们来讲讲这个方式为什么会变得更加灵活

假设我们先创建一个classinfo.properties文件,然后在里面写入className=com.bjpowernode.java.bean.User这样字符串,接着我们构造如下代码

我们先创建字符输入流对象将我们的文件读入,然后创建对应的属性类对象,接着加载对应的文件然后关闭字符输入流,然后通过className来获取到我们的类名,接着我们再通过上面学过的反射机制来实例化对象,这个过程虽然很烦很痛苦,但一切都是值得的

因为当我们搞定这一切之后,我们只需要在我们的文件里改变类名就可以让我们的代码创建不同的对象了,代码本身不需要做改动,这就是其灵活性所在。而我们传统的方式要创建对象的话必须要写一大堆代码,就很痛苦其实

反射机制也是非常重要的一章,后面我们的要学习的很多框架底层都是通过反射原理来进行实现的,如果我们要理解这些框架的底层代码,那就必然要将反射机制吃透了,所以也要认真学习

只让静态代码块执行

获取类路径下的绝对路径

我们之前将我们的文件传入的给字符输入流FileReader的方式是利用相对路径,默认在IDEA中使用的路径是project的根,这样构建代码的方式是具有局限性的,因为它限定了只能在IDEA中使用,如果我们令其离开IDEA,那么这个路径可能就无效了,因此我们要学习新的方式来输入我们的路径,接下来我们就将这种方法

接下来讲的方法的前提是我们的文件必须在类路径下,什么是类路径?其实就是src包下的都是类路径,其他的就不是,src则是类的根路径

ini 复制代码
String path = Thread.currentThread().getContextClassLoader().getResource("asedtgweae.class").getPath();

这里Thread.currentThread();方法是获取当前线程对象,而getContextClassLoader();是线程对象的方法,作用是获取当前线程的类加载器对象,而getResource();是类加载器的方法,当前线程的类加载器默认从类的根路径下加载资源

这样我们获取到这个绝对路径之后只要将这个path的绝对路径放到我们的IO流中对应的代码上就可以了

不过经过测试发现如果是java文件的话似乎要在字符串后面加个.class文件才能正确读取到文件,否则会报空指针异常,为什么会有这种问题?这主要的原因是因为其实src并不是真正意义上的类的根路径,我们之所以他是类的根路径是为了便于理解,因为里面的确存放了很多java文件,而且里面的大多都是类,但其实IDEA的类的根路径其实是在另外一个名叫out的包下,而那个包下会存放我们平时生成的文件,包括字节码文件,点进去看会发现里面没有什么java文件,只有同名字节码文件,而我们默认寻找的位置是就是在那里的,那自然是找不到的,因为那里根本就没有java文件,只有我们加上了.class之后,才能准确定位到我们想要找到的文件

最后还是提醒一下,这个方法的前提是文件要在类路径下,我们才能用这种方式

以流的形式返回

资源绑定器

SUN公司给我们提供了一个十分便利的方法叫资源绑定器,可以用于迅速读取Properties文件,但是前提是还是跟上一节一样,文件必须是在类的根目录下,也就是src下才可以使用这个方法

可以看到我们这里资源绑定器之后,就可以舍弃IO流结合Properties的方法了,直接用资源绑定器ResourceBundle就完了,这可比前面的方便多了,以后我们也是尽量用这种方式来进行Properties文件的加载的

值得一提的是,文件的路径后缀不用加.properties,这个与之前我们学习IO流的时候是有所不同的,后者要加上,而前者不用

类加载器概述

双亲委派机制

获取Field

Field是一个类中的成员变量,这些就是一个类里的属性(Field)

File[] getFields();这个方法可以将Class对象中的所有的属性返回,但是只返回可以访问的公共字段,这是什么意思呢?简而言之就是只能返回public修饰的属性

Field类里的getName();方法是用于只返回属性的名字的,其次是在我们的Class类里也是getName();方法的,调用该方法会返回对象的完整类名,从根目录下开始到最后到类型,还有一个getSimpleName();,调用该方法会返回一个简类名,只返回类名,而不返回前面的目录

Field[] getDeclaredFields();方法可以返回对象的一个数组,而且这个可以返回所有的属性变量,而不是只有public修饰的

field类中的getType();方法,调用该方法会返回一个Class文件,接着再调用返回的Class对象的getName方法就可以获得其类型了,但是由于String类型是引用数据类型,因此其返回的类型是java.lang.String,是包括了路径的,其他基本数据类型就只返回int,boolean这些,那我们还可以调用getSimpleName();方法,令其返回简类型名,这样哪怕是引用类型也只返回其最后的类型名,而不会加上其路径了

field类里的getModifiers();方法,可以获得修饰符列表用对应的数字表示的值,将值传入Modifier.toString();方法中可以获得修饰符的字符串

通过反射机制访问对象属性

上面的代码里我们普通的赋值方式是直接给属性赋值,赋值需要三要素,分别是对象s,属性no,以及要赋的值1111,而我们下面反射机制的代码虽然比较长比较复杂,但其实其本质是一样的

我们这里使用反射机制先获得对象本身,然后利用newInstance();方法创建对象,然后通过getDeclaredField();方法获得其no,这里我们讲一下,我们判断方法的不同是通过方法名来判断的,因此这里调用的getDeclaredField();方法,只要传入对应的名字,那么就可以返回对应的属性

最后我们通过Field类里的set();方法,传入要修改的属性和要修改的值,就实现了三要素,最后就能够完成创建了,最后我们还可以调用Field里的get();方法,传入对应的属性就可以获取其值

访问对象的私有属性

那么我们刚刚拿到的方法是public修饰的,那么我们可以直接拿到private修饰的吗?答案是不行,因为private是被封装的,如果我们用同样的方式构造代码,会报异常

但我们可以调用Field里的setAccessible();方法并传入true,这样就可以打破封装,在外部也可以同样对私有的方法进行修改了

同时这也是反射机制的缺点之一,就是会打破封装,那就不安全

可变长度参数

在讲method之前我们先来做个知识科普,先来讲讲可变长度参数,可变长度参数的语法是int... args,其中args是参数名,想怎么变就怎么变,同时,我们的点一定要标注在数据类型后面且只能有三个点,如果我们运用了可变长度参数放置于方法中,那么我们在调用方法时可以传入任意个同类型的数据来调用该方法

同时这个传入的值到方法中会变为数组的形式,我们可以在方法中完成遍历操作,也可以完成取值那些,如果我们传入一个数组,那么到方法中其还是一个数组。在Idea中我们无法通过ctrl来进入int...里面查看源码,说明压根就没有源码,我猜测这是一种机制而已,运用这种代码编辑方式能让编译器自动将其全部转换为数组并且令方法正常执行,当然底部到底是怎么实现的还不好说,但是我猜测是这样的

最后有一点事可变长度参数在参数列表中必须在最后一个位置上,且只能有一个,这是当然的,因为要在最后一个位置上那就只能有一个

反射Method

首先我们获得里面的方法,调用getDeclaredMethods();方法,返回一个Method[]数组,接着遍历数组,获得其修饰符的代表值调用getModifiers();方法,再调用Modifier.toString将代表值传入就能获得其修饰符的字符串了

要获得其返回值类型就调用getReturnType();方法,会返回一个Class对象,再调用其getSimpleName();方法,则会获得其对应的返回值类型的简略名

获取其方法名就直接调用getName();方法

要获取其修饰符列表就先调用getParameterTypes();方法,会返回一个Class[]数组,里面存放对应方法的Class对象,然后我们通过遍历调用getSimpleName就能够获得其对应的方法了

反射机制的调用方法

首先我们在Field类里讲过,如果我们要获得其属性,我们只需要传入对应的属性名就可以了,因为属性名是唯一的,其能够区别不同的属性,但是在方法里就不是这样了,因为有方法重载,因此我们这里不能单用名字来区别方法,还要加上数据类型,而在Class类中就有这个getDeclaredMethod();方法,调用该方法能够获得Method对象,要调用该方法就要传入参数名,以及任意数量的字节码对象

那么我们在调用方法时如何传入文件对象呢?我们可以采用创建文件对象的第三种方式,直接用数据类型.class的方式就可以创建并传入了,这样我们就可以调用我们想要的方法了,我们的方法中要传入什么类型的参数,要传入多少个,我们只管创造对应数量的文件对象然后传入到getDeclaredMethod();方法中就可以调用了,其会返回一个Method对象,该对象就是我们所需要的方法对象

那我们要如何对方法进行修改呢?这要需要用到Method类中的invoke();方法,调用该方法需要传入文件对象,以及要修改的值就可以了

这里也分四要素,分别是方法对象(loginMethod),以及字节码文件对象(obj),我们要修改的数据("admin","123"),以及承接返回值的变量(retValue),最终我们按照这个思路就构造了上图所示的代码

同样的,也是代码上的构造比普通的方式要来的麻烦,但是比普通的方式而言更加灵活,也更加具有通用性

反射调用构造方法

直接创造无参数构造方法可以直接调用Class中的newInstance方法,默认会调用获取的字节码文件中的无参数构造方法来创建对象。而如果要调用有参数的构造方法来创建对象的话就稍微麻烦一些了,需要先调用getDeclaredConstructor();方法获取其构造方法对象,而构造方法的区分是完全靠数据类型来区分的,因为他们的名字都一样,因此这里只需要传入对应的数据类型的对象就就可以获得了我们所需要的Constructor对象了,然后调用该对象里的newInstance();方法并传入我们要修改的参数就能成功创造对象了,这里要注意的是我们当初获取到的几个有几个参数的构造方法,我们就要对应在创建对象的时候要传入几个对应参数,否则会出错

当然我们也可以直接在调用getDeclaredConstructor();方法时什么都不传入,这就代表着我们要取出无参构造方法,同样再调用newInstance();方法就可以创建对应的对象了

第一个方法已经过时了,不建议用了,因此我们还是用第二个方法比较好,虽然麻烦点

获取父类和父接口

我们可以在Class类中调用其getSuperclass();方法来获得其父类的Class对象,同样可以同getName();方法获得其父类的类名

如果我们要获得其实现的所有接口的话就调用getInterfaces();方法,其会返回一个Class[]数组,因为我们一个类里实现的接口是多个的,而不是一个的,因此返回的Class对象自然也是以数组的形式,我们只要通过遍历并调用getName();方法就可以知道其究竟其所实现的接口的类

注解

注解,或者是注释类型,英文单词是Annotation,是一种引用数据类型,编译之后也会生成对应的class字节码文件,其创建语法是[修饰符列表] @interface 注解类型名{}

使用语法是@注解类型名,没了。注解可以出现在类上,属性上,方法上,变量上

Overrride

Override注解,该注解类里什么内容都没有,什么内容都没有,想到了什么?没错,这时候就想到了之前我们学过的继承接口,也是什么都没有,它的作用是给编译器看的。而实际上我们的Override注解的作用也类似于此,其只能够用于修饰方法,而且其修饰的方法必须是重写了父类方法的方法,否则编译器会报错,而且也只是在编译阶段起作用,运行阶段其实屁用没有

元注解

就是标注在注解类上的注解就是元注解

less 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

可以看到,在注解类Override上也还有两个注解,分别是Target和Retention,那么他们的作用是什么呢?

这里Target注解表示被标注的注解只能够出现在方法上,毕竟我们都能看到那么大个METHOD(方法)不是

而Retention注解在这里表示被注解的方法只保留在java源文件中,生成的class中不会有这个注解,这里标注的是SOURCE

而如果我们的SOURCE换成CLASS中,那么我们的标注的注解会被保留在class文件中,也就是在生成的class文件中也能看到这个注解

而如果我们的SOURCE换成RUNTIME,那么就表示注解不但会被保存在class文件中,而且还会被反射机制所读取

Deprecated

这个注解比较简单,被其修饰的方法会被视为已经过时的方法,我们通常用这个注解来表示一个已过时的方法,这样能够告诉其他程序员这个方法已经过时了,现在有更加好的代替方法

我们来看看该注解类的源码吧

less 复制代码
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE})
public @interface Deprecated {
    String since() default "";
    boolean forRemoval() default false;
}

可以看到该注解被Retention修饰,且带有RUNTIME关键字,其表示的意思是该注解被保存在class文件中且可以被反射机制反射到,之所以要这个RUNTIME,是因为已过时的方法在运行时我们是要求能够让我们的程序读取到其已经过时并发出警告的,运行时会出现信息框,提示程序员该方法已经过时,而要达到这个目标,必然要将该注释保留在字节码文件中,这样我们的程序才能读取到这个Deprecated注解

而我们又可以看到这里的Target上有很多关键字,分别表示其可以放在构造方法,成员变量,本地变量等等地方上

这里再提一嘴,对于没有Target注解的元注解,放啥地方都可以,没有限制

注解中定义属性

String name();一类代码我们称之为属性,虽然看起来很像是缺省的方法,但因为其出现在注解类中,因此其是属性,而不是缺省方法。一旦我们在注解里定义了方法,那么我们在使用注解修饰时就必须给注解附上值,否则编译器会报错,而赋值的方式就如下图所示,采用(属性名=属性值)的方式

但是有一种情况我们可以不给其对应的属性赋值,那就是给年龄后面加上了default关键字并赋值,其代表的意义是如果我们不给其赋值,那么其就默认会指定其值为我们最开始的默认值

学习完了这些之后我们就可以暂时解释一部分代码了

less 复制代码
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE})
public @interface Deprecated {
    String since() default "";
    boolean forRemoval() default false;
}

之所以我们使用Deprecated注解时不用传入对应的属性值也可以调用,是因为其两个属性值都赋予了默认值

属性是value时可以省略

假设我们构造这么一个注解类

scss 复制代码
public @interface MyAnnotaion {
    String value();
}

那么我们在使用注解时,可以用这种方式来给value赋值

我们可以看到我们这里直接赋值就完了,连前面的属性名都省略了,就很舒服,但是这个方法具有局限性,就是在我们的注解类里只能出现一个属性,且属性名为value,否则无法使用这种方式

另外如果在注解类定义两个value的话,编译器会报错

注解数组属性

学习完了上面的内容之后,那么我们现在就能够看懂之前我们学习过的注解类的源码了

less 复制代码
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, MODULE, PARAMETER, TYPE})
public @interface Deprecated {
    String since() default "";
    boolean forRemoval() default false;
}

这里Rention括号里的内容只传入了一个RetentionPolicy.RUNTIME,这是我们显然能够猜测其源码里只有一个value命名的属性,而且由于其调用方式,我们也能轻易猜测出其数据类型应该是个枚举类,可能是数组,我们可以进去看看其源码

less 复制代码
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
    RetentionPolicy value();
}

查看其源码果然只有一个value,再进去看看其数据类型RetentionPolicy看看源码

swift 复制代码
public enum RetentionPolicy {
    SOURCE,
    CLASS,
    RUNTIME
}

果然是个枚举类,不出我们的所料。同样的,Target里应该有一个value命名的数组数据类型对象,点进去看看源码也会发现真的是,而且其数据类型是个枚举类型,不过既然是枚举类型,却没有加点调用,这一点我觉得有点怪

同样的,我们自定义注解类是,如果想要让我们的注解只出现在我们指定的地方,或者想要改变我们注解保存的位置,只要按照上面教的方法给我们的注解标注对应的元注解就行了

反射注解

我们之前我们讲过注解类里如果有元注解@Retention(RetentionPolicy.RUNTIME),那么这个注解不但会保存在class文件里,还能被反射机制反射出来,那么我们要怎么反射注解呢?请看代码

要获取注解,当然首先就应该要获得带着注解的类,因此我们这里先调用forName方法获得对应的类,然后,接着我们要判断类中是否有我们要取出的注解类,此时我们调用isAnnotationPresent();方法,传入对应的注解类就可以判断了,然后如果有的话,我们自然就可以调出该注解了,此时我们调用getAnnotation();方法,该方法要求传入对应的注解类对象,因此我们传入我们自定义的注解类采用.class的形式将注解对象传入,其会返回一个Object类型,而我们需要的是注解类,这里我们用注解类承接,因此要使用向下转型,将其转为我们需要的注解类

如果我们要获取我们的注解里的值,那么我们直接用注解.属性名的方式就可以了,这里我们用value是因为这个注解里存放的属性名就是value,如果其name,那么我们就把value换成name就完了

同时这里获取的对象的值,比方说如果我们的注解里的属性值默认是3,我们传入的是4,那么最终我们得到的结果是4,如果我们啥都没传,那么我们的到结果就是3,因为我们的默认值是3

注解在开发中有什么用

当然是先获得User类了,然后判断User类中是否有Id注解,如果有,我们就取出该类的所有属性,然后一个个遍历,如果我们遍历到了一个int类型的id,就说明其存在该成员变量,那么我们就将代码我们的定义的boolean类型的变量赋值为true

遍历之后我们构造一个判断语句,如果isOk的值不为true,就说明其没有id的成员属性,那么我们直接抛出异常就可以了

那么讲到这里我们应该也能够猜测出注解在实际开发中有什么用了吧?其最大的作用就是可以规范开发,加上了注解,程序员就知道它应该要加上那些东西,不然就会抛异常,后续我们的开发都是要用得上这种方式的,因此注解我们也是要学习好掌握好的

注解的另一大作用是可以简化开发

异常

Java提供了异常的处理方式,当程序中发生了不正常的情况,会触发对应的异常,异常会被不断上抛到JVM中,JVM收到异常之后停止工作,控制台中则会打印对应的异常信息供给给程序员Debug

异常在java里是以类和对象的形式存在的,我们可以用异常类new对象,也可以打印出来

UML图

我们可以用UML图来表述java的继承结构,java中进行软件开发前也需要由架构师用UML统一建模语言设计UML图用于开发,UML图不是只是为了java而服务的,只要是面向对象的语言,都有UML,都需要UML

在UML中可以描述类与类之间的关系,程序流程,对象状态等,而在java的软件开发中,设计师负责设计类,画UML图,而java软件开发人员则需要必须能够看懂这些图

在UML图中,空心箭头表示继承

注意这里的子类只是选取了其中一部分并不是所有,首先所有异常的老祖宗是Object类,其中抛出异常类Throwable是其子类,在Throwable下有Error和Exception两个子类,Error类表示错误,错误一旦发生就不可挽回,会强制停止JVM虚拟机的运行,而Exception类发生的异常则是可挽回的,异常和错误本身都是可抛出的

我们重点来讲Exception的可挽回的异常

Exception异常类又有两个分支,分为运行时异常和编译时异常,但无论是哪个异常都是在运行时发生的。其中编译时异常其实有很多子类,但是这里只用一个子类囊括了,一种简单的省略方式,理解一下

对于编译时异常,如果我们不进行处理的话,那么编译器就会报错,程序不予通过,之所叫它为编译时异常,这其实是因为我们必须在编译时进行处理所以我们叫它编译时异常

对于运行时异常,我们可以选择处理也可以选择不处理

所有的异常都是运行阶段发生的

Java处理异常的机制

java语言中对异常的处理方式有两种

一种是使用throws关键字,抛给上一级

另一种是使用try...catch语句进行异常的捕捉

如果使用throw关键字将异常上抛,调用这个方法的上一级接受到这个异常同样是选择上抛或者是try...catch捕捉两种方式来处理异常,如果不断上抛的话,最终异常处理main方法处,main方法再次上抛就到JVM虚拟机中,JVM虚拟机一旦知道异常,那么它只会做一种处理,就是终止程序的运行

如果选择不断上抛,那么每一层级上抛的异常只能相等或者更多,不能更少

try...catch的语句的用法,try后面接{},括号里接可能会发生异常的方法,后面接catch(),()内接抛出异常的类+变量名+{},{}里接我们发生异常之后我们要执行的语句

try...catch语句是可以加多个catch语句的,分别代表了对不同异常进行的处理,当然对不同异常进行处理需要在catch的小括号填写对应的异常对象类型和变量名,否则编译器会报错

catch语句里的类型异常名也可以填写其父类,其中发生了多态机制,我们可以同时编写多种不同的catch语句,用于精确处理各种不同的异常

运行时异常可以不处理

如果我们希望调用者来处理异常,那么就采用throws上报方式,其他情况采用捕捉处理的方式

异常信息的打印采用的异步线程

异常对象的常用方法

异常对象有两个常用的方法,分别是.getMessage()和.printStackTrace(),前者是获取异常简单的描述信息,后者是打印异常追踪的堆栈信息

如何看异常信息

我们先看包名,上面那些是sun公司写的包的不用管,它们是肯定不会出错的

我们主要看我们自己写的代码,找到自己的包,最上面的位置就是首先出错的位置,因为这个出错,所以后面出错,所以我们直接定位到最上面的一行的代码,先去解决这个问题

当然可能我们的程序是有多个问题的,但是我们仍然是先从上往下去解决代码问题的,一个个来有利于我们排除后面的代码问题是由前面的异常导致的可能

最后我们来说下try...catch语句的好处,其好处是能够提升程序的健壮性,try里面的语句即使出现了问题,catch内部也会进行处理,而且不会导致程序的终止,就算出现了异常,我们也可以继续将代码执行下去,不会导致程序的崩溃

finally子句的使用

在try...catch语句里其实还有一个finally语句,和try...一起组合使用,不能单独出现

定义在finally里的语句是一定会执行的,即使try里面的代码出现了异常

一般我们使用这个子句来实现关闭流的操作

放在finally语句块里的代码块是一定会执行的,除非碰上了System.exit(0);

finally面试题

csharp 复制代码
public class App {
    public static void main(String[] args) {
        int result = m();
        System.out.println(result);
    }
​
    private static int m() {
        int i = 100;
        try {
            return i;
        }finally {
            i++;
        }
    }
}

我想大家应该都会说是101,但是很可惜,答案是100。我想大家一定会非常疑惑,这难道不会先执行i++然后执行返回i的值吗?

之所以会这样,是因为在java语言中,有两条语法规则是不能破坏的

简而言之,return语句必须最后执行,同时我们又不能违背上面的两条规则,而且finally的语句也是必须执行的

因此程序内部会这样执行

ini 复制代码
int i = 100;
int j = i;
i++;
return j;

首先,由于在方法体里代码是遵从自上而下的顺序执行的,通过自上而下的分析,编译器认为会返回100的值,这是自上而下执行的第一条规则

然后由于finally的代码又必须执行,但同时又要返回100,所以java自动创建了一个j用于保存原来的值,然后进行i++的操作,最后执行return返回j,这样就同时符合了三条规则(当然,这是我猜的,不一定对)

这就是为什么最后的结果会是100的原因

简而言之,我们一般不再finally语句块里做赋值,我们 一般是对其进行释放的

自定义异常

自定义异常的步骤首先第一步是编写一个类继承Exception或者RuntimeException,第二步是是在该类里生成两个构造方法,分别是有参和无参

我们是根据我们的实际需求来决定我们继承运行时异常类还是编译时异常类的,这主要取决于我们的业务需求

我们继承对应的类,则说明我们我们定义的类是该异常类的子类,同样也能够调用父类里有的方法,而我们也可以重写对应的方法,父类里提供了两个构造方法,那我们子类里同样要提供这样的两个方法,用于我们去调用异常

通过创建自定义异常对象实现自己抛出自己定义的某个异常对象------当程序出现错误时

throw new ......(...),其中throw表示抛出异常对象,new表示创建异常对象,...代表我们所定义的异常类名(...)表示我们括号里所加入的对异常进行描述的字符串

当然我们不能忘了我们要在我们的方法里加上throws .....

集合

集合就是一个容器,可以用来容纳其他类型的数据,且可以一次容纳多个对象

集合中存储的都是java对象的引用,也就是内存地址,不可以存储基本数据类型

集合里存放的内存地址可以是另外一个集合的内存地址

不同集合对应的不同的数据结构,数据结构有很多种,例如数组(ArrayList),二叉树(TreeMap),链表(LinkedList),哈希表(HashMap)都是数据结构

集合类存在于java.util.*;包下,所有的集合类和集合接口类都是在该包下

集合继承结构

在java里集合分为两大类,一类是以单个方式存储元素,另一类是以键值对的方式存储元素。前者的超级父接口是java.util.Collection;后者是java.util.Map;简而言之就是前者是一个一个地来存储元素的,后者是通过一对一对存储的

我们这里先主要讲Collection接口,也就是单个储存方式的

首先Collection接口继承了一个父类接口叫Iterable接口,该接口里有iterator方法,该方法的作用是返回一个迭代器,我们在Collection接口里可以调用这个方法从而获得一个Iterator对象,也就是获得一个迭代器

Collection所有的集合都有这个接口,换言之则是所有集合都是可遍历的

返回的迭代器对象iterator也是一个接口,接口里有三个方法分别是hashNext();next();remove();这三个方法的作用是为了完成集合的迭代

注意Collection与迭代器的对象是关联关系的,也就是has a,意为Collection拥有这个迭代器对象,就好像是顾客有菜单这样一个道理,并不是继承关系

在Collection接口类下还有两个接口(其实有很多接口,也有很多方法,但我们这里只提这两个,因为他们比较重要),分别是List接口类与Set接口类,这两个类继承了,又叫泛化了Collection类,继承和泛化可以简单理解为一个意思

List接口类储存元素特点是有序可重复,存储的元素有下标且遵从先进先出的原则

而Set接口类则反过来,它存储的元素时无序的不可重复的

List接口类下有三个普通类去实现它(同样的,这三个也是比较重要的,不是说只有这三个,下面不做特殊声明的都是同种情况),这三个类分别是ArrayList,LinkedList和Vector

ArrayList类和Vector类集合底层都是采用数组数据结构,不同的是前者是线程安全的,后者则不是

查看源码也能够发现Vector的所有方法都有synchronized关键字修饰,所以简称安全,但是因为其效率较低,有其他方案代替它,所以现在Vector使用比较少了

LinkedList类集合底层采用了双向链表的数据结构

Set接口类下有HashSet普通类去继承,有SortedSet接口类去继承,

SortedSet接口的特点其实和其父类Set差不多,也是无序不可重复的,但是不同的是SortedSet集合里把数据放于此能够将元素按照大小进行排序,称为可排序的集合

注意有序的排序并不是一个意义啊,实际上SortedSet即集合里的元素还是无序不可重复的,只是会进行排序而已,而对于Arraylist集合而言是一开始就有序的,不必再排序,而且有序其还带有的重点特性是先进先出,而对于无序的集合而言可没有这种机制

而在这个接口类之下还有TreeSet类去实现它在SortedSet集合里存储元素

HashSet集合在new的时候底层实际是new了一个HashMap集合,向HashSet集合中存储元素实际上是村塾到了HashMap集合中去了,HashMap集合是一个哈希数据结构

而TreeSet集合的底层实际上是TreeMap,同理往TreeSet里放数据实际是把数据放到TreeMap集合中去了,TreeMap集合底层采用二叉树的集合结构

接下来我们来讲讲另外一个Map接口类

首先Map集合和Collection集合没有关系,其次是Map集合是以key和value的这种键值对的形式来存储元素的,再者是key和value都是存储java对象的内存地址,最后是所有的Map集合的key特点是无需不可重复的,其实这里Map集合的key和Set集合的存储元素特点是相同的,这也能解释为什么在上一个Collection类里Set集合下其子类的对象里是有Map类型的,因为本来他们的特点就差不多是吧

Map集合下有两个类去实现Map接口,分别是HashMap集合与Hashtable集合,这两者的集合底层都是哈希表数据结构,不同的是前者是非线程安全的,而后者反之。当然就与Vector集合一样,Hashtable由于有synchronized关键字所以效率较低,现在使用较少

在Hashtable下还有一个Properties的实现类,该实现类是线程安全的,因为其继承Hashtable,因此储存元素时也是采用key和value的形式储存,且其key和value只支持String类型,该类被称为属性类,比较重要,后期总会用上

同时Map还有一个名为SortedMap的接口类,接口类下有TreeMap的实现类,其底层代码是二叉树结构

众所周知Map集合的key和Set集合储存元素的特点是相同的,这是因为实际上我们往Set集合里的HashSet集合和TreeSet集合放元素时,其底层是调用的是HashMap,所以实际上往HashSet和往HashMap集合里放元素,其效果都是一样的,TreeMap和TreeSet同理

下面完整的MapUML图

集合迭代

Collection里的迭代器方法Iterator iterator()可以返回一个迭代器,我们可以用这个迭代器进行集合的遍历

Iterrator方法之所以在Collection接口里有是因为Collection接口上有一个超级父类接口称为Iterable,Collection继承了它,自然也有它的方法,由于Collection也是接口,所以无法实现这个方法,所以Collection的子类对该方法进行了实现了

迭代器内有三个方法

其中前两个方法比较重要,我们这里主要讲前两个方法,也就是hasNext();和next();

boolean hsaNext();方法的主要作用是看看迭代器能否继续迭代,如果能,即使下一个位置里还有元素,则会返回true,如果不能,即使下一个位置没有元素了,则会返回fasle

next();方法则会让迭代器指向下一位元素,同时将指向的元素返回

迭代器最初生成的时候并不指向集合里的第一位元素,而是在集合之外

迭代器对象最开始在集合外,要先调用hasNext方法令其判断下一位有没有元素,返回true就调用next();方法取出下一位的内存地址并返回,无论这个对象是什么对象,其超级父类必定是object,所以用object承接其返回引用最好,然后再调用hasNext();和next();方法,如此循环往复,直到hasNext();返回false时结束循环

如果我们用Collection中的hashSet来new对象的话,那么迭代器对象就会按照hashSet类中的无序不可重复的特点来去取出元素

如果我们放入的数字是在byte类型的范围的,也就是在[-127,127]范围之内的话,那么其仍然是有序的,这里的原因也很简单,因为这些数据先储存在了常量池里了

集合元素删除

返回的迭代器对象必须在删除动作之后而不能在这之前

使用迭代器的remove方法会自动更新迭代器,我们就不必再删除之后手动再new一个新的迭代器了

集合内常用方法

其中contains方法内部是通过equals方法进行的比较,同理还有remove方法,因此我们推荐任何实体类都要重新equals方法

泛型

泛型是不使用的话就默认表示集合里可以存放Object类及其子类的数据,当我们使用泛型之后集合就只运行放一种我们所指定的数据

泛型是JDK5.0之后才推出的新特性

泛型存在的意义是给编译器看的,使用了泛型是在编译阶段统一类型,实际运行的时候没什么作用

泛型存在菱形运算符机制,简单来说就是前面规定泛型后面的实现类中不必再规定

自定义泛型

一般来说我们自定义的泛型符号称为通配符

如果我们想要使用泛型,那么我们就要在对应的类名之后加上<>,<>号中间加的是标识符,一般为E和T,其他的也可以,这样就表示这个类可以使用泛型机制

我们在对应的方法的方法名后的小括号里加上我们在<>里写上的标识符并命名,然后我们在主方法里想要使用泛型机制的时候,只需要在创建对象的时候在类型名后加上<>并写上我们想要限制的数据类型,然后在new后面的数据类型里只加<>就可以了

这个可以简单理解为E其实就是Object,只是我们调用的时候用不用而已,如果我们用了就可以将Object类型转换为其他的类型,这样就只能存储我们指定的类型

泛型的限制

  1. 基本类型不可以用于泛型中
  2. instanceof的检测不能用于泛型中,简而言之就是不要对两个相同的指定泛型的对象使用instanceof进行检测,由于泛型在运行时会被擦除,最终这个检测会失效
  3. 不可以调用泛型内部的各种方法或者是变量
  4. 泛型不可以用于实例化数组,但编码上可以创建,也不可以实例化对象,也就是new一个泛型对象是不被允许的
  5. 使用泛型参数化类型的数组不可对其进行实例化,简单来说就是不可以用两个相同的类,一个创建数组,另一个创建对象,都指定不同的泛型,但是最终将第二个对象赋给第一个数组。由于泛型擦除的特性,最终赋予的代码是不会报错的,但是实际调用时,会抛出异常

泛型的类型限界

泛型的类型限界简单来说就是将泛型能代表的对象限定在某个范围内,一般使用extends或者是super关键字来实现

假设我们想要做一个找出数组中的一个最大值的方法,如果直接用泛型类,那么编译器会识别出其内部没有compareTo方法,此时就可以用类型限界,将其范围固定在Comparable中,这样就可以正确调用Comparable的方法了,但这里还会存在一些不够规范的问题,最规范的写法是第三种,比较复杂,但是幸运的是,这个阶段没有比这个更加复杂的写法了,把这个记住就可以了

ini 复制代码
//必须传入实现Comparable的类
public static <AnyType extends Comparable> AnyType findMax(AnyType[] arr){
    int maxIndex = 0;
    for (int i = 1; i < arr.length; i++) {
        if(arr[i].compareTo(arr[maxIndex])>0){
            maxIndex=i;
        }
    }
    return arr[maxIndex];
}
​
//必须传入实现Comparable的类,且该类内部若有引用类,则该类必须为同一个泛型类
public static <AnyType extends Comparable<AnyType>> AnyType findMax(AnyType[] arr){
    int maxIndex = 0;
    for (int i = 1; i < arr.length; i++) {
        if(arr[i].compareTo(arr[maxIndex])>0){
            maxIndex=i;
        }
    }
    return arr[maxIndex];
}
​
//必须传入实现Comparable的类,且该类内部若有引用类,则该类必须为同一个泛型类或者其父类
public static <AnyType extends Comparable<? super AnyType>> AnyType findMax(AnyType[] arr){
    int maxIndex = 0;
    for (int i = 1; i < arr.length; i++) {
        if(arr[i].compareTo(arr[maxIndex])>0){
            maxIndex=i;
        }
    }
    return arr[maxIndex];
}

foreach

在JDK5.0之后推出了新特性,叫增强for循环,或者叫foreach

用增强for循环时我们要在括号里定义元素类型,而变量名可以随便取,:后面跟对应的集合名或者是数组名,要输出的()里我们填写对应的变量名就可以了

增强for循环,也就是foreach循环的缺点是其内部没有下标,没有下标我们也没法进行取出修改等动作,所以在我们需要使用下标的循环中,不建议使用foreach循环

使用foreach循环来循环集合时,我们在括号里填写的元素数据类型,可以填写其父类的,可以填写其自身,但不能填写其子类或者与其毫无关联的,否则会报类型转换异常

List接口

List是Collection接口的子接口,其自己继承了Collection接口的方法,而它自身也有自己的特有的方法

List集合里储存的元素的特点是有序的,且遵从先进先出原则的,可重复的

ArrayList

ArrayList是List接口的一个子实现类,底层采用数组数据结构,其集合底层是Object类型的数组Object[];,它是非线程安全的

其源码中的初始化容量为10,但这个10存在的前期是有元素放进去,否则它都是先创建一个容量为0的数组就搁那放着

对ArrayList里的add方法而言,如果集合的容量不足够的话,那么集合会自动扩容1.5倍,然后如果还是不够,就在扩容1.5倍,如此循环往复

数组扩容的效率比较低,尽量在首次创建的时候就要选择一个合适的初始化容量

LinkedList

LinkedList同样是List接口的一个子实现类,底层采取链表数据结构

链表的增删元素的效率很高,但是查询的效率并不好

sun公司写的程序里,LinkedList使用的是双向链表,双向链表的定义与单项链表类似,其集合内的基本单元也是Node,但不同的是,一个Node对象里有两个地址,一个保存上一个对象的引用,一个保存下一个对象的引用,首节点的首地址为null,尾结点的尾地址为null

LinkedList集合没有初始化容量

Vector

Vector底层也是一个数组,其次Vector的初始化容量为10,最后Vector每次扩容都是扩容到它原来的两倍

Vector中所有的方法都是线程同步的,都带有synchronized关键字,是线程安全的,但是效率比较低,现在有其他的方法来代替

Map

Map类接口的特征只和Map有关系和Collection没有继承关系

Map集合是以key和value的方式来储存数据的,称之为键值对

key和value都是引用数据类型,两个变量都是存储对象的内存地址

最后key起到主导的地位,而value是key的一个附属品

Map接口也是支持泛型的,而且其泛型可以有两个,中间用逗号隔开,说明不管是key还是value都可以分别用泛型指定相应的值

Set<Map.Entry<K,Y>> entrySet()方法,该方法用于将Map集合转换为Set集合,返回的Set集合里的对象是包含了key和value两个对象的组合对象,集合的元素的类型是Map.Entry<K,Y>

遍历Map集合可以用foreach循环,也可以使用迭代器遍历,使用后者要先将Map转换为Set,再调用Set集合的迭代器

哈希表/HashMap

HashMap集合底层是哈希表/散列表的数据结构

哈希表到底是怎样的数据结构呢?其实,就可以简单理解为是数组和单向链表的结合体,将两者的数据结构结合起来,充分发挥其优点,我们就称之为哈希表

看HashMap的源码,在源码里我们可以查看到很多Node[],这其实就已经说明了HashMap底层的确是以数组节点为基本单元的,而且这个数组还是一维数组

map.put(k,v);方法其实现原理是先将k,v封装到Node对象当中去,然后调用k的hashcode();方法得出hash值,然后通过哈希算法得到对应的数组下标,该数组下标上如果没有任何元素,那么就直接将Node添加到这个位置,如果有元素,那么会拿着k的equals方法与元素一个个比对,如果全部返回false,则其会添加到该下标的单向链表的最后一位,如果有一位返回true,那么其会将返回true的那个节点的value进行替换

哈希表就是数组与链表的结合,单说查询效率和增删效率其都不如完全的数组结构或者是链表结构,但是它合二为一,就让哈希表的查询效率和增删效率都不错

哈希表查找和增删都依赖于equals和hashCode();方法,因此在哈希表里的这两个方法都是需要重写的,否则不能够正常比较

HashMap集合里的key的特点是无序不可重复,之所以无序,是因为我们不知道我们加入的元素到底加到哪里去了,它有可能就放在节点数组里当单向链表头,也可能加到某个单向链表的结尾,而我们取出的时候只能是通过数组的下标一个个取的,那必然会导致我们元素的无序。同时我们也能够解释为什么虽然取出来是无序的,但之后如果不对程序进行修改,无论再怎么取都还是原来的排序,这是因为哈希表的数组在那时已经定好了,如果不进行修改,那么无论怎么取都是原来的值

而之所以不可重复的原因,是因为在哈希表里如果出现相同的key,其使用equals比较时就会返回true,这样就会发生替换而不换增加,所以不可重复

在哈希表内,同一个单向链表上的hash是相同的,这是其实很容易理解,这要不一样那它怎么加到这个下标上的单向链表来的嘛是不是

但是同一个单向链表上key和key的equals方法比较结果肯定是false,这是必然的,因为哈希表集合的每一个单向链表上的节点都是不可重复的,当然就只能返回false

使用哈希表时要避免散列分布不均匀的情况

hashMap默认的初始化容量是16,其扩容的方式是1进行右移位的运算<<,运算4位,之所以用右移位的方式是因为右移位运算的方式其效率高于普通的运算,因此采用移位运算

哈希表数据结构的默认加载因子是0.75,其意思表达式当集合底层的数组容量达到75%的时候,集合就会开始扩容

哈希表集合的容量必须是2倍数,这是规定,2倍数有利于有效提高哈希表集合存取删减的效率,也能让我们更好达到散列分布均匀的要求,如果我们定义的容量不是二的倍数,那么它会自动帮我们优化成二的倍数再生成

在JDK8之后,HashMap增加了新特性,其特性是如果单向链表的元素超过8个,单项链表这种数据结构会变成红黑树数据结构(红黑树就是二叉树),当红黑树上的节点数量小于6时,又会重新把红黑树数据结构变成单向链表数据结构。这种方式也是为了提高检索效率,二叉树的检索会再次缩小扫描范围,提高效率

Hashtable

Hashtable是线程安全的,因而效率较低,使用较少

Hashtable和HashMap一样,底层都是哈希表数据结构

Hashtable初始化的容量为11,默认加载因子是0.75f

Hashtable的扩容是:原容量*2+1

Properties

Properties是一个Map集合,继承Hashtable类,Properties的key和value都是String类型,其次,Properties不但被称为属性类对象,且其是线程安全的

TreeSet

TreeSet的集合底层是一个TreeMap,而TreeMap的集合底层是二叉树数据结构,相当于TreeSet集合底层是二叉树数据结构

TreeSet集合中的元素时需不可重复,但是可以按照元素的大小顺序自动排序称之为可排序集合

TreeSet集合里的元素会按照大小顺序自动排序,如果是字符串的话则会比较字母表中前面的字母排在前头

如果要让TreeSet对自定义对象实现按大小排序,那么对象必须实现Compatable接口或者再创建指定对象时就提供指定的排序规则

二叉搜索树

TreeSet/TreeMap是自平衡二叉树 ,是遵从左小右大的原则进行存放的

遍历二叉树有三种方式,这三种方式分别是前序遍历 (根左右),中序遍历 (左根右)以及后序遍历(左右根)

Collections工具类

Collections工具类,其余Collection都在java.util包下,但不同的是前者是一个工具类,而后者是接口,这两者是不同的

Collections.synchronizedList();方法,该方法可以将不是线程安全的List集合变为线程安全的

Collections.sort();方法,该方法可以对集合进行排序,用这种方法对List集合进行排序,要保证List集合内中实现了Comparable接口,否则编译器会报错

Collections.sort();方法还有一个重载方法,该方法需要传入一个List集合和一个比较器,这时我们即使不实现Comparable接口,也可以进行正常的排序了,排序的规则就是我们在比较器里定义的规则

相关推荐
Dear.爬虫3 小时前
Golang中逃逸现象, 变量“何时栈?何时堆?”
开发语言·后端·golang
努力的小郑4 小时前
MySQL索引(三):字符串索引优化之前缀索引
后端·mysql·性能优化
IT_陈寒4 小时前
🔥3分钟掌握JavaScript性能优化:从V8引擎原理到5个实战提速技巧
前端·人工智能·后端
程序员清风5 小时前
贝壳一面:年轻代回收频率太高,如何定位?
java·后端·面试
考虑考虑5 小时前
Java实现字节转bcd编码
java·后端·java ee
AAA修煤气灶刘哥5 小时前
ES 聚合爽到飞起!从分桶到 Java 实操,再也不用翻烂文档
后端·elasticsearch·面试
爱读源码的大都督6 小时前
Java已死?别慌,看我如何用Java手写一个Qwen Code Agent,拯救Java
java·人工智能·后端
星辰大海的精灵6 小时前
SpringBoot与Quartz整合,实现订单自动取消功能
java·后端·算法
天天摸鱼的java工程师6 小时前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
一乐小哥6 小时前
一口气同步10年豆瓣记录———豆瓣书影音同步 Notion分享 🚀
后端·python