Java基础 - 多线程


多线程

多线程是提升程序性能非常重要的一种方式,使用多线程可以让程序充分利用CPU资源,提高CPU的使用效率,从而解决高并发带来的负载均衡问题。它的优点有:(1)资源得到更合理的利用;(2)程序设计更加简洁;(3)程序响应更快,运行效率更高。缺点是:(1)需要更多的内存空间来支持多线程;(2)多线程并行访问的情况可能影响数据的准确性;(3)数据被多线程共享,可能出现死锁的情况。

什么是进程?什么是线程?

进程是计算机正在运行的一个独立的应用程序,例如打开一个浏览器就是运行一个进程,一个应用程序至少有一个进程。

线程是组成进程的基本单位,可以完成特定的功能,一个进程是由一个或多个线程组成的。

进程与线程的区别在于进程在运行时拥有独立的内存空间,即每个进程所占用的内存都是独立的,互不干扰。而多个线程是共享内存空间的,但是每个线程的执行是相互独立的,同时线程必须依赖于进程才能执行,单独的线程是无法执行的,由进程来控制多个线程的执行。

通常意义的多线程是指在一个进程中,多个线程同时执行。注意这里的同时执行不是真正意义上的同时执行,系统会自动为每个线程分配CPU资源,在某个具体时间段内CPU的资源被一个线程占用,在不同的时间段内由不同的线程来占用CPU资源,所以多个线程还是在交替执行,只不过因为CPU运行速度太快,感觉上是在同时执行。

线程的使用

Java中实现多线程的常用方式有两种:基础Thread类和实现Runnable接口。

继承Thread类

继承Thread类的实现步骤:(1)创建自定义类并继承Thread;(2)重写Thread的run()方法,并编写该线程的业务逻辑代码

public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
        for (int i = 0; i < 1000; i++){
            System.out.println("+++++ Thread +++++");
        }
    }
}
public class MyThread extends Thread{
    public void run(){
        for (int i = 0; i < 1000; i++){
            System.out.println("----- MyThread -----");
        }
    }
}

从上面程序的运行结果可以看到,当前程序中有两个线程,一个是主线程即main方法,另一个是子线程MyThread。需要注意的是开启子线程是通过调用线程对象的start()方法来完成的,一定不能调用run()方法,调用run()方法是普通的方法调用,相当于在主线程中顺序执行了两个循环,并没有开启一个可以和主线程抢占CPU资源的子线程。所以调用run()方法并不是多线程,还是一个主线程执行。

实现 Runnable 接口

Runnable接口的实现步骤:(1)创建自定义类并实现 Runnable接口;(2)实现 run()方法,编写该线程的业务逻辑代码

public class MyRunnable implements Runnable{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++){
            System.out.println("----- myRunnable ------");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
        for (int i = 0; i < 100; i++){
            System.out.println("       Runnable      ");
        }
    }
}

MyRunnable的使用与MyThread略有不同,MyRunnable相当于定义了线程业务逻辑,它本身并不是线程对象,所以还需要实例化Thread对象,然后将MyRunnable对象赋给Thread对象。然后调用Thread对象的start()方法来启动该线程。

线程的状态

线程共有5种状态,在特定的情况下,线程可以在不同的状态之间切换,5种状态如下:

  • 创建状态:实例化了一个新的线程对象,还未启动。
  • 就绪状态:创建好的线程对象调用start()方法完成启动,进入线程池等待抢占CPU资源。
  • 运行状态:线程对象获取了CPU资源,在一定的时间内执行任务。
  • 阻塞状态:正在运行的线程暂停执行任务,释放所占用的CPU资源。并在解除阻塞之后也不能直接回到运行状态,而是重新回到就绪状态,等待获取CPU资源。
  • 终止状态:线程运行完毕或因为异常导致该线程终止运行。

线程调度

线程休眠

休眠指让当前线程暂停执行,从运行状态进入阻塞状态,将CPU资源让给其他线程的一种调度方式,要通过调用sleep()方法来实现。

sleep(long millis)是Java.lang.Thread类中定义的方法,使用时需要指定当前线程休眠的时间,传入一个long类型的数据作为休眠时间,单位为毫秒。任何一个线程的实例化对象都可以调用该方法。

public class MyThread extends Thread{
    public void run(){
        for (int i = 0; i < 100; i++){
            if (i == 10){
                try{
                    sleep(1000);
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
            }
            System.out.println("----- MyThread -----");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
        for (int i = 0; i < 100; i++){
            System.out.println("       Thread      ");
        }
    }
}

运行上面代码可以发现,当子线程中 i=10 时,会休眠1000毫秒,1000毫秒之后线程进入就绪状态,重新等待系统为其分配CPU资源。这是在线程内部执行休眠操作;也可以在外部使用线程时执行休眠操作,如下所示。

public class MyThread extends Thread{
    public void run(){
        for (int i = 0; i < 100; i++){
            System.out.println("----- MyThread -----");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        try{
            thread.sleep(5000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        thread.start();
        for (int i = 0; i < 100; i++){
            System.out.println("       Thread      ");
        }
    }
}

主程序中创建子线程,程序运行时子线程先休眠5000毫秒之后再启动。其他线程也是,经过5000毫秒后竞争CPU资源。

主线程并不是一个手动实例化的线程对象,不能直接调用sleep()方法,这种情况下可以通过Thread类的静态方法currentThread来获取主线程对应的线程对象,然后调用slepp()方法。

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++){
            if (i == 2){
                try {
                    Thread.currentThread().sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("       Thread      ");
        }
    }
}

注意:无论通过那种方式调用sleep()方法都需要注意处理异常,因为sleep()方法在定义时声明了可能会抛出的异常InterruptedException。

线程合并

合并的意思是将指定的某个线程加入到当前线程中,合并为一个线程,由两个线程交替执行变成一个线程中两个子线程顺序执行,即一个线程执行完毕之后再来执行第二个线程,通过线程对象的join()方法来实现合并。

线程甲在执行到某个时间点的时候调用线程乙的join()方法,则表示从当前时间点开始CPU被线程乙独占,线程甲进入阻塞状态。直到线程乙执行完毕,线程甲进入就绪状态,等待获取CPU资源进入运行状态继续执行。

public class MyThread extends Thread{
    public void run(){
        for (int i = 0; i < 100; i++){
            System.out.println(i + "----- MyThread -----");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
        for (int i = 0; i < 100; i++){
            if (i == 5){
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(i + "       MainThread      ");
        }
    }
}

在Main类的主线程中开启子线程。当主线程循环执行到 i==5 的节点时,将子线程合并到主线程中,此时主线程进入阻塞状态。而后子线程执行,当子线程执行完毕之后,主线程继续执行。

join()方法存在重载: join(long millis),如果某个时间点在线程甲中调用了线程乙的sleep(1000)方法,表示从当前这一时刻起,线程乙会独占CPU资源,线程甲进入阻塞状态。当线程乙执行了1000毫秒之后,线程甲重新进入就绪状态。此时,无论线程乙是否允许完成,线程甲都会来竞争CPU资源。而调用join()方法,则是线程乙允许完成后,线程甲才来竞争资源。

线程礼让

线程礼让是指在某个特定的时间点,让线程暂停抢占CPU资源的行为,即从运行状态或就绪状态来到阻塞状态,从而将CPU资源让给其他线程来使用。Java中的线程礼让,通过调用yield()方法完成。

假如有线程甲和线程乙在交替执行,在某个时间点线程甲做出了礼让,所以在这个时间节点线程乙拥有了CPU资源,执行其业务逻辑,但不是说线程甲会一直暂停执行,直到线程乙执行完毕再来执行线程甲。线程甲只是在特定的时间节点礼让,过了这个时间节点,线程甲再次进入就绪状态,和线程乙再次争夺CPU资源。

线程中断

有多种情况可以造成线程停止运行,例如线程执行完毕之后会自动停止该线程;线程执行过程中遇到错误会抛出异常并停止该线程;线程在执行过程中会根据需求手动停止该线程。

Java中实现线程中断机制有如下几种方法:

  • public void stop()
  • public void interrupt()
  • public void isInterrupted()

stop()方法在新版本的JDK中已经不推荐使用。

interrupt是一个实例方法,当一个线程对象调用该方法时,表示中断当前线程对象,每个线程对象都是通过一个标志位来判断当前是否为中断状态。

isInterrupted()方法就是用来获取当前线程对象的标志位,true表示清除了标志位,当前线程对象已经中断;false表示没有清除标志位,当前对象没有中断。

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread();
        System.out.println(thread.getState());
        thread.interrupt();
        System.out.println(thread.isInterrupted());
    }
}

// 输出
NEW
false

getState()方法可以获取当前线程对象的状态,实例化一个线程对象thread,但是并未启动该对象,直接中断。

从输出结果可以看到,NEW表示当前线程对象为创建状态。false表示当前线程并未中断,因为当前线程没有启动,所有不存在中断,不需要清除标志位。

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    if (i == 5){
                        Thread.currentThread().interrupt();
                    }
                    System.out.println("runTime .......");
                }
            }
        });
        thread.start();
        System.out.println(thread.getState());
        System.out.println(thread.isInterrupted());
        System.out.println(thread.getState());
    }
}
// 输出
???????   p192

线程同步

Java中允许多线程并行访问,即同一个时间段内多个线程同时完成各自的操作。但是这样会带来一个问题,当多个变量同时操作一个共享数据时,可能会导致数据不准确的问题。

public class MyThread extends Thread{
    private static int num = 0;
    public void run(){
        try {
            Thread.currentThread().sleep(1);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        num++;
        System.out.println(Thread.currentThread().getName() + "是当前的第" + num + "个访问者!");
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread t1 = new Thread(myThread, "线程1");
        Thread t2 = new Thread(myThread, "线程2");
        t1.start();
        t2.start();
    }
}

// 输出
线程1是当前的第2个访问者!
线程2是当前的第2个访问者!

从输出结果可以看到此时访问数据是有问题的,两个线程都显示为第2个访问者。问题出现的原因是两个线程在同时访问静态资源num。

这个是多线程同时访问共享数据时带来的隐患。解决方法是使用线程同步。可以通过 synchronized 修饰方法来实现线程同步,每个Java对象都有一个内置锁,内置锁会保护使用 synchronized 关键字修饰的方法,要调用该方法必须先获得内置锁,否则就处于阻塞状态。

public class MyThread extends Thread{
    private static int num = 0;
    public synchronized void run(){    // 添加 synchronized
        try {
            Thread.currentThread().sleep(1);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
        num++;
        System.out.println(Thread.currentThread().getName() + "是当前的第" + num + "个访问者!");
    }
}

// 输出
线程1是当前的第1个访问者!
线程2是当前的第2个访问者!

代码的运行逻辑是:假设线程1先到,获取了run()方法的锁,之后线程2到了,发现 run()方法被锁起来了。要调用方法必须拿到锁,但是此时锁被线程1获取了,只有当线程1调用完方法之后才会释放锁。执行了一次num++,然后输出信息,之后线程1释放内置锁。线程2获取到了内部锁,由阻塞状态进入运行状态,调用run()方法,再一次执行num++并输出信息。两个线程是按照先后顺序来执行的,并没有同时去修改num,所以看到了正确的结果。

synchronized 可以修饰实例方法,也可以修饰静态方法,但是两者在使用上有区别。

修饰静态方法

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Main.test();
                }
            });
            thread.start();
        }
    }

    private static void test() {
        System.out.println("start...");
        try{
            Thread.currentThread().sleep(1000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}

// 输出
start...
start...
start...
end...
end...
end...

从运行结果可以发现,运行结果不是“start…”“end…”成对输出,而是单独输出。原因是多线程并行访问,一个线程输出了“start…”之后,休眠1000ms,在输出“end…”,所有就给了其他线程输出的机会。现在使用synchronized 关键字分别修饰静态方法和实例方法。

修饰静态方法

private synchronized static void test() {
    System.out.println("start...");
    try{
        Thread.currentThread().sleep(1000);
    }catch (InterruptedException e){
        e.printStackTrace();
    }
    System.out.println("end...");
}

// 输出
start...
end...
start...
end...
start...
end...

此时程序运行的结果是“start..”“end…”成对输出,因为我们给test()方法加了一把锁,线程1先到,拿到了这把锁,然后执行test的业务逻辑。在整个执行过程中,其他线程到了也只能处于阻塞状态等待,因为它们拿不到锁。只有当线程1执行完毕释放了锁,其他线程才可以拿到锁进而执行方法。所以执行顺序是线程按序执行。

修饰实例方法

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    new Main().test();
                }
            });
            thread.start();
        }
    }

    private synchronized void test() {
        System.out.println("start...");
        try{
            Thread.currentThread().sleep(1000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}

// 输出
start...
start...
start...
end...
end...
end...

运行程序结果可以看到,此时的内置锁并没有为一个线程锁定资源,加锁的效果并没有实现。这是因为当前锁定的是一个实例方法,每一个线程都有这样的一个实例方法,相互之间是独立的。即test方法并不是被所有线程所共享的,实际的运行情况是每一个线程都获取自己的锁,然后并行访问,相互之间并没有“你运行、我等待”的关系,所以给实例方法添加synchronized关键字并不能实现线程同步。

在前面统计访问量的代码中,synchronized修饰的也是实例方法,为什么就可以实现同步呢?因为实例方法中操作的变量num是静态的,所以还是多线程在共享资源,线程同步的本质是锁定多个线程所共享的资源。synchronized还可以修饰代码块,会为代码块加上内置锁,从而实现同步。

修饰代码块

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Main.test();
                }
            });
            thread.start();
        }
    }

    private static void test() {
        synchronized (Main.class){
            System.out.println("start...");
            try{
                Thread.currentThread().sleep(1000);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            System.out.println("end...");
        }

    }
}

// 输出
start...
end...
start...
end...
start...
end...

synchronized()内设置需要加锁的资源,静态方法是属于类的方法,不属于任何一个实例对象,所以静态方法中的 synchronized()只能锁定类,不能锁定实例。同理,静态方法中也不能锁定实例变量,只能锁定静态变量。

在实例方法中添加synchronized()修饰的实例方法,结果是没有锁住资源,因为synchronized()锁定的是this,即当前的实例化对象。每个线程都有一个实例对象,相互独立,并不是共享资源,所有没有实现线程同步。改进方法是:需要锁住共享资源,实例对象是每个线程独有的,类则是共享的,所以锁住类即可

线程安全的单例模式

单例模式是一种常见的软件设计模式,其核心思想是一个类只有一个实例对象,由多个线程来共享该实例对象资源。在某些系统中,只有一个实例对象资源很重要。例如售票系统,共1000张票,分10个窗口出售,则这10个窗口就必须共享这1000张票的实例对象。如果每个窗口都有自己的实例对象,就变成了每个窗口都有1000张票可以出售,有悖于真实逻辑。单例模式实现核心是共享实例对象,那么就把实例对象定义为静态。

public class MyThread extends Thread{
    private static MyThread instance;
    private MyThread(){
        System.out.println("creatMyThread...");
    }
    public static MyThread getInstance(){
        if (instance == null){
            instance = new MyThread();
        }
        return instance;
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread instance1 = MyThread.getInstance();
        MyThread instance2 = MyThread.getInstance();
    }
}

// 输出
creatMyThread...

这里虽然只创建了一个实例对象,也实现了共享,但是并不是真正的单例模式。并且在编写代码时需要考虑多线程并行访问的情况,现在将代码修改为多线程并行访问。

public class Main {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                MyThread instance = MyThread.getInstance();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                MyThread instance = MyThread.getInstance();
            }
        }).start();
    }
}

// 输出
creatMyThread...
creatMyThread...

这里创建了两个实例对象,原因是线程1和线程2是并行访问的。线程1先来判断 instance==null 是成立的,然后线程1来实例化对象。正在此时,实例化对象的操作还没有完成,线程2来了,先判断 instance==null 是成立的,于是线程2也执行了实例化对象的操作,所以导致实例化了两个对象。

为了通过加锁实现线程同步,可以给getInstance()方法加一把锁,用 synchronized 来修饰方法。

public synchronized static MyThread getInstance(){
    if (instance == null){
        instance = new MyThread();
    }
    return instance;
}
// 输出
creatMyThread...

synchronized 可以修饰方法,也可以修饰代码块。接下来,通过同步代码块的方式来实现单例模式。

public class MyThread extends Thread{
    private volatile static MyThread instance;
    private MyThread(){
        System.out.println("creatMyThread...");
    }
    public static MyThread getInstance(){
            synchronized (MyThread.class){
                if (instance == null){
                    instance = new MyThread();
                }
            }
        return instance;
    }
}

// 输出
creatMyThread...

这里使用了 volatile 关键字修饰instance,volatile的作用是可以使内存中的数据对线程可见,这句话是什么意思呢?首先要说明一下Java的内存模型,一个线程在访问内存数据时,其实不是拿到该数据本身,而是将该数据复制保存到工作内存中。相当于取出一个副本,对工作内存中的数据进行修改,再保存到主内存中,即主内存对线程是不可见的。所以当线程1拿到锁,并锁定整个类之后,就实例化了instance对象,即“instance = new MyThread();”。但是此时的instance是工作内存中的数据,还需要将工作内存中的数据保存到主内存中。然而锁定的只是实例化的步骤,保存到主内存的步骤没有加锁。所以工作内存中的instance完成实例化之后,还未更新到主内存之前就释放了锁。线程2立即获取锁,又从主内存复制了一份数据,此时的数据还是null。线程2又在工作内存中完成了一次实例化,然后线程1和线程2再将它们各自实例化之后的数据保存到主内存中。

死锁

使用 synchronized 可以实现线程同步,这可以解决多线程并行访问数据带来的安全问题,但是也会带来一个问题 – 死锁。

死锁的意思是每个线程都占用一个资源并且不愿意释放,而且任意一个线程想继续执行就必须获取其他线程的资源,那么所有的线程都处于阻塞状态,程序无法向下执行也无法结束。
破解死锁的方法是:唯有某个线程愿意作出让步,贡献出自己的资源给其他线程使用,获取到资源的线程就可以执行自己的业务方法,执行完毕后会释放它所占用的两个资源,其他线程就可以依次获取资源来执行业务方法。
????

重入锁

重入锁(ReentrantLock)是对 synchronized 的升级,synchronized 是通过JVM实现的,ReentrantLock是通过JDK实现的。重入锁指可以给同一个资源添加多个锁,并且解锁的方式与synchronized也有不同。synchronized 的锁是线程执行完毕之后会自动释放的,ReentrantLock的锁必须手动释放,可以通过ReentrantLock实现访问量统计。

public class MyThread extends Thread{
    private static int num = 0;
    private ReentrantLock reentrantLock = new ReentrantLock();
    public void run(){
        reentrantLock.lock();
        num++;
        System.out.println(Thread.currentThread().getName() + "是当前的第" + num + "个访问者!");
        reentrantLock.unlock();
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread t1 = new Thread(myThread, "线程1");
        Thread t2 = new Thread(myThread, "线程2");
        t1.start();
        t2.start();
    }
}
// 输出
线程1是当前的第1个访问者!
线程2是当前的第2个访问者!

首先需要实例化 ReentrantLock 的成员变量,在业务方法中需要加锁的地方直接调用对象的 lock()方法即可,同理需要解锁的地方直接调用对象的 unlock()方法即可。使用重入锁的时候需要注意加了几把锁就必须释放几把锁

ReentrantLock除了可重入之外,还有一个可中断的特点,可中断是指某个线程在等待获取锁的过程中可主动终止线程,通过调用对象的lockInterruptibly()来实现。

ReentrantLock还具备限时性的特点,指可以判断某个线程在一定的时间内能否获取锁,通过调用tryLock(long timeout, TimeUnit unit)方法来实现。

public class MyRunnable implements Runnable{
    public ReentrantLock reentrantLock = new ReentrantLock();
    @Override
    public void run() {
        try {
            if (reentrantLock.tryLock(3, TimeUnit.SECONDS)){
                System.out.println(Thread.currentThread().getName()+ "get lock");
                Thread.currentThread().sleep(5000);
            }
            else {
                System.out.println(Thread.currentThread().getName() + "not lock");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (reentrantLock.isHeldByCurrentThread()){
                reentrantLock.unlock();
            }
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        new Thread(myRunnable,"线程1").start();
        new Thread(myRunnable,"线程2").start();
    }
}
// 输出
线程1get lock
线程2not lock

线程1和线程2并行访问,业务方法的执行需要5000ms。reentrantLock.tryLock(3,TimeUnit.SECONDS)表示如果线程启动之后的3s内该线程没有拿到锁,则返回false,反之返回true。很显然5000ms大于3s,会有一个线程是肯定拿不到锁的。


文章作者: zerollone
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 zerollone !
  目录