java 基础概念

90 阅读15分钟

为了更好理解 java 代码而不是只把功能实现,有必要了解一下 java 的基础概念,这对疑难杂症修复,写代码效率都是有帮助的!

字符串对比

使用 == 是比较引用,使用 equals() 是比较内容

String a = '123';
a == '123'; // java 会把所有定义的字符串存到常量池栈内存中,a 和 '123' 引用地址都在常量池,所以返回 true

String b = new String('123');
b == '123'; // 若是使用 new String 创建的字符串则是会在堆中创建新对象,所以这是用 == 比较是返回 false 的 false

b.equals('123'); // equals 是比较内容,所以返回 true

注意:部分包装类型(下面有写:如 Integer)会对特定范围的值进行缓存(-128~127),直接赋值时可能复用对象

Integer a = 127;
Integer b = 127; // 读取的是 a 缓存的对象,并不是创建新对象
System.out.println(a == b); // true

List 一些方法,如contains() indexOf()内部也是使用 equals 进行比较的

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("A", "B", "C");
        System.out.println(list.contains(new String("C"))); // true
        System.out.println(list.indexOf(new String("C"))); // 2
    }
}

如上所示,方法传入的 ”C” 与创建时 List.of 传入的 “C“ ,其实是不同的对象,但是依旧返回了true、索引,可以看出方法内部是调用 equals() 进行内容比较,由此也可以推出,传给方法的对象,必须实现了 equals() 方法,如上的 String 是自带 equals() 方法的,如果是自定义的类,必须自定义 equels() 方法

多态

多态顾名思义是多种形态,是指运行时才能决定调用哪个类型的方法

举例:

有一个 Occupation 职业父类,包含一个 getSalary 方法,Teacher Doctor 等子类继承 Occupation 并复写了 getSalary 方法

有一个接收 Occupation 职业类型参数的方法,调用参数的 getSalary 获取工资方法,由于不知道传入的是什么类型的参数,是 Teacher 还是 Doctor ,也就是要等运行时,才能确定调用哪个类型的 getSalary 方法,这就是多态。

包装类型

基本类型都有对应的包装类型

image.png

包装类型属于对象

包装类型属于对象,所以能够设置成 null,基本类型不可,所以实体类中数字类型应该优先设置为Integer,若确定字段不会为null,则设置成int更好

包装类型提供了一些静态方法

int num = Integer.parseInt("123");

自动装箱和自动拆箱

基本类型和包装类型之间会自动进行类型转换

Integer n = 100; // 自动装箱:100 是 int,自动转换为 Integer 了
int n1 = n; // 自动拆箱:Integer 自动转为 int

自动转换类型会加大内存性能开销,有一个小优化技巧:在进行循环或者计算时可以将 Integer 转为 int 再进行


Integer total = entity.getTotal();
int primitiveTotal = (total != null) ? total : 0; // 拆箱并处理 null

泛型需使用包装类型

泛型只能处理对象,所以必须使用包装类型,原理是:泛型是通过类型擦除实现的,也就是编译时会把类型擦除为 Object,添加或读取时,通过转换实现(如通过 (String) 转换为 String),所以泛型的类型参数必须是 Object 的子类

String 属于引用类型

在java中,String是引用类型而不是基础类型,因此可以给它赋值为 null,也可以很方便使用String的实例方法

String s = null;

String str = "hello";
int len = str.length();

另外拼接字符串拼接推荐使用 SringBuilder 而不是加号拼接,原因是加号拼接时会创建新的 String 对象并舍弃旧的,造成性能开销,StringBuilder 则不会

反射

JVM 动态加载 class ,运行时,读取到时才加载

JVM会为每个加载的 class 、 interface 创建 Class 实例,保存其信息,如字段、方法、类名、包名、父类等等,通过 Class 实例读取这些信息称为反射

有三个方法获取 Class 实例

方法一:直接通过一个class的静态变量class获取:

Class cls = String.class;

方法二:通过实例提供的getClass()方法获取:

String s = "Hello";
Class cls = s.getClass();

方法三:如果知道一个class的完整类名,可以通过静态方法Class.forName()获取:

Class cls = Class.forName("java.lang.String");

至于如何读取 Class 实例的信息,如字段,方法等用到再查吧!

注解

注解感觉有点像 vue 的自定义指令,自定义指令是对被使用的元素做一些处理,而注解是对被使用的字段或方法做一些处理

public class Person {
    @Range(min=1, max=20) // 检查长度
    public String name;
}

定义注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Range {
	int min() default 0;
	int max() default 255;
}

使用 @interface 定义注解

用在注解上的注解为元注解,如上例: Retention 定义了注解的周期类型,@Target 定义了应用的位置为应用于字段

  • 仅编译期:RetentionPolicy.SOURCE

    只在编译期使用,不会打包进 class 文件,如 @Override:让编译器检查该方法是否正确地实现了覆写

  • 仅class文件:RetentionPolicy.CLASS

    会被打包进class文件,比如有些工具会在加载class的时候,对class做修改处理,实现一些的功能

  • 运行期:RetentionPolicy.RUNTIME

     在程序运行期能够读取的注解,如上面的 Range 是需要在程序运行时读取,并进行判断处理的
    

处理注解

注解可以使用反射读取,如上面的Range,可以写一个方法进行读取并进行判断

void check(Person person) throws IllegalArgumentException, ReflectiveOperationException {
    // 遍历所有Field:
    for (Field field : person.getClass().getFields()) {
        // 获取Field定义的@Range:
        Range range = field.getAnnotation(Range.class);
        // 如果@Range存在:
        if (range != null) {
            // 获取Field的值:
            Object value = field.get(person);
            // 如果值是String:
            if (value instanceof String s) {
                // 判断值是否满足@Range的min/max:
                if (s.length() < range.min() || s.length() > range.max()) {
                    throw new IllegalArgumentException("Invalid field: " + field.getName());
                }
            }
        }
    }
}

泛型

顾名思义:广泛的类型,感觉可以理解为方法,有一个类型参数,传入什么类型,里面就定义什么类型

泛型是通过类型擦除实现的,上面有提到

泛型编写

public class Main {
    public static void main(String[] args) {
        Pair<Integer> p = new Pair<>(123, 456);
    }
}
class Pair<T> {
    private T first;
    private T last;
    public Pair(T first, T last) {
        this.first = first;
        this.last = last;
    }
    public T getFirst() {
        return first;
    }
    public T getLast() {
        return last;
    }
    public void setFirst(T first) {
        this.first = first;
    }
    public void setLast(T last) {
        this.last = last;
    }
}

extends 子类泛型

假设定义一个 add() 静态方法,以下例子会报错,因为 add() 接收 Number 类型的 Pair ,而传入的是 Integer 类型的

public class Main {
    public static void main(String[] args) {
        Pair<Integer> p = new Pair<>(123, 456);
        int n = add(p);
        System.out.println(n);
    }

    static int add(Pair<Number> p) {
        Number first = p.getFirst();
        Number last = p.getLast();
        return first.intValue() + last.intValue();
    }
}

想要不报错,可改为接收泛型为 Number 子类的 Pair

static int add(Pair<? extends Number> p) {
    Number first = p.getFirst();
    Number last = p.getLast();
    return first.intValue() + last.intValue();
} 

extends 泛型是不能修改的,以下会报错

static int add(Pair<? extends Number> p) {
    Number first = p.getFirst();
    Number last = p.getLast();
    p.setFirst(new Integer(first.intValue() + 100));
    p.setLast(new Integer(last.intValue() + 100));
    return p.getFirst().intValue() + p.getLast().intValue();
}

细想一下这也很合理,站在方法的角度想想:我接收的是 Number 子类,我不知道你会传入 Integer 还是 Double,但是你设置时却设置一个固定的类型 Integer,假如你传入的是 Double 类型的,那不是完蛋了!

super 父类泛型

super 和 extend 是相反的,

extend 是子类泛型,super 是父类泛型

extend 不能修改,super 不能读取

以下例子 setSame() 接收 Integer 以及其父类(Number Object)类型的 Pair

public class Main {
    public static void main(String[] args) {
        Pair<Number> p1 = new Pair<>(12.3, 4.56);
        Pair<Integer> p2 = new Pair<>(123, 456);
        setSame(p1, 100);
        setSame(p2, 200);
        System.out.println(p1.getFirst() + ", " + p1.getLast());
        System.out.println(p2.getFirst() + ", " + p2.getLast());
    }

    static void setSame(Pair<? super Integer> p, Integer n) {
        p.setFirst(n);
        p.setLast(n);
    }
}

class Pair<T> {
    private T first;
    private T last;

    public Pair(T first, T last) {
        this.first = first;
        this.last = last;
    }

    public T getFirst() {
        return first;
    }
    public T getLast() {
        return last;
    }
    public void setFirst(T first) {
        this.first = first;
    }
    public void setLast(T last) {
        this.last = last;
    }
}

super 父类泛型无法读取,以下会报错

Integer x = p.getFirst();

细想一下同样很合理,我接收的是 Integer 及其父类的 Pair ,我不知道你是 Integer 还是 Number ,现在却定义为 Integer,假如你传入的是 Number 那不是完蛋了!

数组和List 以及 Map

js 只有数组的概念,java 中 数组 和 List 是不同的

List 更灵活,平时使用多,与 js 数组更类似

数组性能更好,适合长度固定,高性能场景

image.png

java 中 Map 的内部其实是数组,他会根据 key 计算出 索引,进行存取

进程线程

一个进程可以包含一个或多个线程,且至少会有一个线程

可以类比到计算机系统理解:一个任务是一个进程,比如浏览器是一个进程, word 是一个进程,word 中又有一些子任务,如拼写检查、打印等,这些子任务可以理解为线程

开启线程

java 实际是一个 jvm 进程,主线程执行 main 方法,方法中又可以开启多个线程,线程的 star() 方法实际会调用内部的 run() 方法

// 开启线程,方式1
public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 启动新线程
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

// 开启线程,方式2
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start(); // 启动新线程
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

// 开启线程,方式3
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("start new thread!");
        });
        t.start(); // 启动新线程
    }
}

开启线程后,两个线程就开始同时运行了,执行顺序是无法确定的,如下代码,只能确定一开始会打印 main star… 之后的语句打印顺序是无法确定的

public class Main {
    public static void main(String[] args) {
        System.out.println("main start...");
        Thread t = new Thread() {
            public void run() {
                System.out.println("thread run...");
                System.out.println("thread end.");
            }
        };
        t.start();
        System.out.println("main end...");
    }
}

但是也可以通过 join 方法等待线程执行结束

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("hello");
        });
        System.out.println("start");
        t.start(); // 启动t线程
        t.join(); // 此处main线程会等待t结束
        System.out.println("end");
    }
}

守护线程

所有线程结束后, jvm 才能结束,但是某些任务,是一直循环,不会结束的,例如心跳检查,这些进程可以设置成守护进程,jvm 结束时,无论守护线程是否还在运行,都会被强制终止

只要在调用start()方法前,调用setDaemon(true)即可把该线程标记为守护线程

Thread t = new MyThread();
t.setDaemon(true);
t.start();

中断线程

通过线程对象的 interrupt() 方法中断线程

线程内部通过调用 isInterrupted()

若线程在 sleep()wait() 或 I/O 阻塞时,会抛出 InterruptedException,需捕获并终止

public class InterruptExample {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    System.out.println("Working...");
                    Thread.sleep(1000); // 模拟阻塞操作
                } catch (InterruptedException e) {
                    System.out.println("Thread interrupted, exiting.");
                    // 恢复中断状态并退出
                    Thread.currentThread().interrupt();
                }
            }
        });

        worker.start();
        Thread.sleep(3000); // 主线程等待3秒
        worker.interrupt(); // 中断子线程
    }
}

线程同步

上面有提到线程执行是不确定的,假设有个公共变量 int num = 0;,线程1 和 线程2 都加对其加 1 num += 1,那么 num是否等于 2 ?

答案是不一定,因为相加分三步:1. 加载 2. 相加 3. 储存。有可能 线程1 加载后还没进行相加和储存,线程2 也加载了,这时两个线程加载的 num 都是 0,相加后, num为 1

image.png

当多个线程同时修改共享数据时,要保证逻辑正确,就要加锁,锁住后,该线程执行时,其他线程必须等待(同步)。

image.png

使用 synchronized 关键字,传入同一对象即可加锁,不传默认是 this 实例对象,两段需要同步执行的代码必须传同一对象,如果传的是不同的对象,用的不是同一个锁,那么还是会并行执行的

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var add2= new AddThread2();
        add.start();
        add2.start();
        add.join();
        add2.join();
        System.out.println(Counter.count);
    }
}

class Counter {
    public static final Object lock = new Object();
    public static int count = 0;
}

class AddThread extends Thread {
    public void run() {
        synchronized(Counter.lock) {
            Counter.count += 1;
        }
    }
}

class AddThread2 extends Thread {
    public void run() {
        synchronized(Counter.lock) {
            Counter.count += 1;
        }
    }
}

也可以封装 Counter 对象

public class Counter {
    private int count = 0;

    public void add() {
        synchronized(this) {
            count += 1;
        }
    }
    
    // 写法 2
    // public synchronized void add(int n) {
	  //  count += n;
		// }
    
    public int get() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
			  Counter c1 = new Counter();
			  
			  Thread t1 = new Thread(() -> c1.add());
        Thread t2 = new Thread(() -> c1.add());
				
				t1.start();
        t2.start();
        
        // 主线程等待 t1 和 t2 执行完毕
        t1.join();
        t2.join();
        
        System.out.println(c1.get());
    }
}

上面例子有个问题,由于 get方法不是同步的,当有线程调用 get时,有可能会读取到缓存的值,而不是相加后最新的值,解决办法是将 get 设置成同步,或者 count 使用 volatile 修饰,volatile的作用是保证修改立刻刷新到内存,保证读取从内存读取而非缓存

// 方法1 get 方法设置成同步
public synchronized int get() {
    return count;
}
// 方法2 count 使用 volatile 修饰 
private volatile int count = 0;

死锁

两个或多个线程因争夺资源(锁)而陷入相互等待的状态,导致所有线程都无法继续执行,编程中必须警惕这种问题

案例1:互相获取对方的锁,从而两个锁都无法释放,陷入僵持

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

两个线程,线程1调用 add(),线程2调用 dec()

线程1 获取 lockA,线程2 获取 lockB

此时,线程1 获取 lockB,线程2 又获取 lockA 了,两边都僵持着,锁都无法释放,另一个线程也就获取不到锁,陷入了无限的等待

案例2:锁一直不释放,其他方法无法获得锁,陷入阻塞

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
        }
        return queue.remove();
    }
}

线程1 调用 getTask() ,获取 this 锁,发现队列为空,然后陷入了循环,锁无法释放,这时线程2 调用 addTask() ,但无法获得锁,从而阻塞

wait notify/notifyAll

上面的案例2 可以通过 wait() notify() 解决

wait() 先让出锁,进入等待

notify() 唤醒等待中的线程

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
        this.notify();
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
	        this.wait();
        }
        return queue.remove();
    }
}

但是 notify() 只能唤醒一个线程,如果有多个线程进入了等待,就需要 notifyAll() 全部唤醒

import java.util.*;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        var q = new TaskQueue();
        var ts = new ArrayList<Thread>();
        for (int i=0; i<5; i++) {
            var t = new Thread() {
                public void run() {
                    // 执行task:
                    while (true) {
                        try {
                            String s = q.getTask();
                            System.out.println("execute task: " + s);
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                }
            };
            t.start();
            ts.add(t);
        }
        var add = new Thread(() -> {
            for (int i=0; i<10; i++) {
                // 放入task:
                String s = "t-" + Math.random();
                System.out.println("add task: " + s);
                q.addTask(s);
                try { Thread.sleep(100); } catch(InterruptedException e) {}
            }
        });
        add.start();
        add.join();
        Thread.sleep(100);
        for (var t : ts) {
            t.interrupt();
        }
    }
}

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
        this.notifyAll();
    }

    public synchronized String getTask() throws InterruptedException {
        while (queue.isEmpty()) {
            this.wait();
        }
        return queue.remove();
    }
}

如上例,5个线程都调用了 getTask() 此时就要 notifyAll() 唤醒全部线程

ReentrantLock

ReentrantLock 需要手动上锁和释放

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock 可以尝试获取锁,如果获取不到返回 false,不会导致锁死

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

ReadWriteLock

ReadWriteLock 允许多个线程读取,只允许一个线程写入,提高了读取效率

public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    // 注意: 一对读锁和写锁必须从同一个rwlock获取:
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

StampedLock

ReadWriteLock 有个问题,就是有线程读取时无法写入,也就是要等所有线程读取完成才能写入,这降低了写入效率,这种属于悲观锁

悲观锁:读取时拒绝写入 乐观锁:读取时可以写入,乐观的估算不会有写入

StampedLock 则允许读取中写入,但是这样也会有问题,就是读取的可能不是最新写入的数据,所以读取方法要判断下是否正在写入,如果有需要再通过获取悲观读锁再次读取。乐观锁顾名思义:乐观的估算写入的概率不高,所以程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

Semaphore

Semaphore 可以实现同一时刻最多有N个线程能访问

public class AccessLimitControl {
    // 任意时刻仅允许最多3个线程获取许可:
    final Semaphore semaphore = new Semaphore(3);

    public String access() throws Exception {
        // 如果超过了许可数量,其他线程将在此等待:
        semaphore.acquire();
        try {
            // TODO:
            return UUID.randomUUID().toString();
        } finally {
            semaphore.release();
        }
    }
}

线程池

频繁创建和销毁大量线程需要消耗大量资源,可以创建一组线程执行任务,哪个线程空闲就用哪个。

import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建线程池:核心线程2,最大线程4,存活时间10秒,队列容量10
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            2, 4, 10, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(10),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy()
        );

        // 提交10个任务
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " executed by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000); // 模拟任务耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

参考

廖雪峰JAVA教程