Java多线程基础(一)

本文详细介绍了Java多线程的基础知识,包括线程的创建、同步、安全问题以及线程的停止。通过实例展示了继承Thread类、实现Runnable接口以及使用匿名内部类创建线程的方法,并探讨了线程同步中的死锁问题和wait/notify机制。此外,还分析了线程的优先级、内存可见性以及synchronized关键字在保证线程安全方面的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1、多线程基础

一个采用了多线程技术的应用程序可以更好地利用系统资源。其主要优势在于充分利用了CPU的空闲时间片,可以用尽可能少的时间来对用户的要求做出响应,使得进程的整体运行效率得到较大提高,同时增强了应用程序的灵活性。

更为重要的是,由于同一进程的所有线程是共享同一内存,所以不需要特殊的数据传送机制,不需要建立共享存储区或共享文件,从而使得不同任务之间的协调操作与运行、数据的交互、资源的分配等问题更加易于解决。

1.1 线程和进程

进程:

是指一个内存中运行的应用程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程;进程也是程序的一次执行过程,是系统运行程序的基本单位;系统运行一个程序即是一个进程从创建、运行到消亡的过程。

线程:

进程内部的一个独立执行单元;一个进程可以同时并发的运行多个线程,可以理解为一个进程便相当于一个单 CPU 操作系统,而线程便是这个系统中运行的多个任务。

进程与线程的区别:

进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。

线程:堆空间(数据)是共享的,栈空间是独立的,线程消耗的资源比进程小的多。

栈空间:内存地址,相当于Java中的变量名称

堆空间:存放数据的地方

注意:

  1. 因为一个进程中的多个线程是并发运行的,那么从微观角度看也是有先后顺序的,哪个线程执行完全取决于 CPU 的调度,程序员是不能完全控制的(可以设置线程优先级,设置了优先级也不一定能保证优先级高的线程先执行,只能有限的管理线程)。而这也就造成的多线程的随机性。
  2. Java 程序的进程里面至少包含两个线程,主线程也就是 main()方法线程,另外一个是垃圾回收机制线程。每 当使用 java 命令执行一个类时,实际上都会启动一个 JVM,每一个 JVM 实际上就是在操作系统中启动了一个 线程,java 本身具备了垃圾的收集机制,所以在 Java 运行时至少会启动两个线程
  3. 由于创建一个线程的开销比创建一个进程的开销小的多,那么我们在开发多任务运行的时候,通常考虑创建 多线程,而不是创建多进程。

1.2 多线程的创建

创建Maven工程,编写测试类

1.2.1、继承Thread类

第一种继承Thread类 重写run方法

public class Demo1CreateThread extends Thread {

    public static void main(String[] args) throws InterruptedException {

        System.out.println("-----多线程创建开始-----");
        // 1.创建一个线程
        CreateThread createThread1 = new CreateThread();
        CreateThread createThread2 = new CreateThread();
        // 2.开始执行线程 注意 开启线程不是调用run方法,而是start方法
        System.out.println("-----多线程创建启动-----");
        createThread1.start();
        createThread2.start();
        System.out.println("-----多线程创建结束-----");
    }

    static class CreateThread extends Thread {
        public void run() {
            String name = Thread.currentThread().getName();
            for (int i = 0; i < 5; i++) {
                System.out.println(name + "打印内容是:" + i);
            }
        }
    }
}
1.2.2、实现Runnable接口

实现Runnable接口,重写run方法

实际上所有的多线程代码都是通过运行Thread的start()方法来运行的。因此,不管是继承Thread类还是实现Runnable接口来实现多线程,最终还是通过Thread的对象的API来控制线程的。

public class Demo2CreateRunnable {

    public static void main(String[] args) {
        System.out.println("-----多线程创建开始-----");
        // 1.创建线程
        CreateRunnable createRunnable = new CreateRunnable();
        Thread thread1 = new Thread(createRunnable);
        Thread thread2 = new Thread(createRunnable);
        // 2.开始执行线程 注意 开启线程不是调用run方法,而是start方法
        System.out.println("-----多线程创建启动-----");
        thread1.start();
        thread2.start();
        System.out.println("-----多线程创建结束-----");
    }

    static class CreateRunnable implements Runnable {

        public void run() {
            String name = Thread.currentThread().getName();
            for (int i = 0; i < 5; i++) {
                System.out.println(name + "的内容:" + i);
            }
        }
    }
}

实现Runnable接口比继承Thread类所具有的优势:

  1. 适合多个相同的程序代码的线程去共享同一个资源。
  2. 可以避免java中的单继承的局限性。
  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和数据独立。
  4. 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类
1.2.3、匿名内部类方式

使用线程的内匿名内部类方式,可以方便的实现每个线程执行不同的线程任务操作

public class Demo3Runnable {
    public static boolean exit = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                String name = Thread.currentThread().getName();
                for (int i = 0; i < 5; i++) {
                    System.out.println(name + "执行内容:" + i);
                }
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                String name = Thread.currentThread().getName();
                for (int i = 0; i < 5; i++) {
                    System.out.println(name + "执行内容:" + i);
                }
            }
        }).start();

        Thread.sleep(1000l);
    }
}}
1.2.4、测试多线程执行效率

​ 很多初学者在学习多线程基础时基本都是上述代码,学的也是一脸懵逼,都说多线程效率高,但就上述代码而言看出来什么来,下面根据一个小案例测试下多线程的效率,来个for循环,循环1W次打印到控制台。

  • 不使用多线程输出1W次信息到控制台
package com.yizhan.thread;

/**
 * 功能描述:不使用多线程打印1W次信息到控制台
 *
 * @Author: apple
 * @Date: 2021/10/10 6:30 下午
 */
public class DemoRunnable {
    public static void main(String[] args) throws InterruptedException {
        long startTime=System.currentTimeMillis();   //获取开始时间
        for(int i=0;i<10000;i++){
            Thread.sleep(10); // 睡眠0.01秒
            System.out.println("内容是:"+i);
        }
        long endTime=System.currentTimeMillis(); //获取结束时间
        System.out.println("程序运行时间: "+(endTime-startTime)+"ms");
    }
}

运行时间为:112815ms(112.815 秒)

  • 使用多线程测试输出1W次信息到控制台

说明:下述代码不懂的先不用了解,在后面文章会讲到,先看看效率。

package com.yizhan.thread;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 功能描述:测试多线程执行效率
 *
 * @Author: apple
 * @Date: 2021/10/10 6:30 下午
 */
public class DemoRunnable3 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(10);
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        long startTime=System.currentTimeMillis();   //获取开始时间
        for (int i = 0;i< 10;i++){
            executorService.execute(new ThreadRunable(countDownLatch));
        }
        try{
            countDownLatch.await();
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println("此时全部线程均执行结束:over");
        executorService.shutdown();  //关闭线程池
        long endTime=System.currentTimeMillis(); //获取结束时间
        System.out.println("程序运行时间: "+(endTime-startTime)+"ms");
    }

    static class ThreadRunable implements Runnable{
        private final CountDownLatch countDownLatch;    // 计数器
        ThreadRunable(CountDownLatch countDownLatch) {
            this.countDownLatch = countDownLatch;
        }

        @Override
        public void run() {
            work();
            countDownLatch.countDown();
        }

        private void work(){
            for(int i=1;i<=1000;i++){
                System.out.println(Thread.currentThread().getName()+"内容是:"+i);
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }
}

运行时间为:11494ms(11.494 秒)

​ 创建了10个线程,每个线程执行1000次,可以看出使用多线程,程序执行的效率是非常高的,也希望大家能熟练掌握Java中的多线程,上述概述只是为了能看到执行时间的效果,代码等价与下述代码:

package com.yizhan.thread;
public class DemoRunnable2 {
    public static void main(String[] args) throws InterruptedException {
        for(int i =0;i<10;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    String name = Thread.currentThread().getName();
                    for(int i = 1;i <= 1000;i++){
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(name+"内容是:"+i);
                    }
                }
            }).start();
        }
    }
}

1.2.5、守护线程

Java中有两种线程,一种是用户线程,另一种是守护线程。

用户线程是指用户自定义创建的线程,主线程停止,用户线程不会停止。

守护线程当进程不存在或主线程停止,守护线程也会被停止。

  • 不是守护线程时执行以下代码
package com.yizhan.thread;
/**
 * 功能描述:守护线程
 *
 * @Author: apple
 * @Date: 2021/10/10 9:58 下午
 */
public class Demo3Daemon {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i=0;i<10;i++){
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("================子线程执行================");
                }
            }
        });
        //thread.setDaemon(true); // 设置为守护线程
        thread.start();     // 启动子线程

        for(int i=0;i<5;i++){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("*************主线程执行*************");
        }

        System.out.println("*************主线程执行完毕*************");
    }
}

自定义的线程不是守护线程时,当main方法执行完毕时,自定义的线程还在执行(输出结果中主线程执行完毕,子线程还在执行)。

主线程执行
子线程执行
主线程执行
子线程执行
子线程执行
主线程执行
子线程执行
主线程执行
子线程执行
主线程执行
主线程执行完毕
子线程执行
子线程执行
子线程执行
子线程执行
子线程执行

将上述代码中的thread.setDaemon(true);注释取消掉再次执行,输出结果为:

主线程执行
子线程执行
子线程执行
主线程执行
主线程执行
子线程执行
主线程执行
子线程执行
主线程执行
主线程执行完毕

主线程(main)方法循环五次就结束了,而子线程循环的是10次,但是输出的只有4次,这是因为主线程循环五次后结束了,守护线程也会跟着结束。

1.3、线程安全

1.3.1、卖票案例

如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的,反之则是线程不安全的。

package com.yizhan.thread;

/**
 * 功能描述:卖票案例
 *
 * @Author: apple
 * @Date: 2021/10/10 10:16 下午
 */
public class DemoTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();   // 创建卖票实例

        // 创建三个线程
        Thread t1 = new Thread(ticket,"窗口1");
        Thread t2 = new Thread(ticket,"窗口2");
        Thread t3 = new Thread(ticket,"窗口3");

        // 执行线程
        t1.start();
        //t2.start();
        //t3.start();
    }

    static class Ticket implements Runnable{
        private int ticket = 10;
        @Override
        public void run() {
            String name = Thread.currentThread().getName(); // 获取当前线程名称(售票窗口名称)
            // 循环卖票
            while(true){
                sell(name);
                // 当票数为0时 退出循环
                if(ticket <= 0){
                    break;
                }
            }
        }

        /**
         * 卖票
         * @param name 窗口名称
         */
        private void sell(String name){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 当票数大于0时表示有票,执行卖票操作
            if(ticket > 0){
                System.out.println(name+"卖:"+ticket+"号票");
                ticket --;
            }
        }
    }
}

上述代码创建了三个线程,只启动了一个线程,也就是单线程卖票,不会出现票数问题,输出结果应该是从10到1依次卖出。

输出结果:

窗口1卖:10号票
窗口1卖:9号票
窗口1卖:8号票
窗口1卖:7号票
窗口1卖:6号票
窗口1卖:5号票
窗口1卖:4号票
窗口1卖:3号票
窗口1卖:2号票
窗口1卖:1号票

现在将另外启动线程的注释取消掉,再次执行查看结果:

窗口1卖:10号票
窗口2卖:10号票
窗口3卖:10号票
窗口1卖:7号票
窗口3卖:6号票
窗口2卖:7号票
窗口1卖:4号票
窗口2卖:4号票
窗口3卖:2号票
窗口2卖:1号票
窗口1卖:1号票

问题:

  • 重复卖票和漏卖票数:这是因为当有一个线程执行卖票后,执行System.out.println(name+"卖:"+ticket+"号票");,下面的ticket --;还没有执行减1的动作,另外一个线程也去执行了System.out.println(name+"卖:"+ticket+"号票");,两个线程拿到的都是没有减票后的数字,所以造成了重复卖票的情况。而漏卖票数也是基于重复卖票的,三个线程同时对一个数字(10号票为例)进行减1操作(10-1-1-1=7)。

线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写 操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步, 否则的话就可能影响线程安全。

1.3.2、线程同步
  • 同步代码块,在对全局或静态变量进行写操作的地方使用锁
package com.yizhan.thread;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 功能描述:卖票案例
 *
 * @Author: apple
 * @Date: 2021/10/10 10:16 下午
 */
public class DemoTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();   // 创建卖票实例

        // 创建三个线程
        Thread t1 = new Thread(ticket,"窗口1");
        Thread t2 = new Thread(ticket,"窗口2");
        Thread t3 = new Thread(ticket,"窗口3");

        // 执行线程
        t1.start();
        t2.start();
        t3.start();
    }

    static class Ticket implements Runnable{
        private int ticket = 100;
        Object lock = new Object(); // 创建锁
        @Override
        public void run() {
            String name = Thread.currentThread().getName(); // 获取当前线程名称(售票窗口名称)
            // 循环卖票
            while(true){
                sell(name);
                // 当票数为0时 退出循环
                if(ticket <= 0){
                    break;
                }
            }
        }

        /**
         * 卖票
         * @param name 窗口名称
         */
        private void sell(String name){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock) {   // 使用锁
                // 当票数大于0时表示有票,执行卖票操作
                if (ticket > 0) {
                    System.out.println(name + "卖:" + ticket + "号票");
                    ticket--;
                }
            }
        }
    }
}

运行后不会出现线程不安全的问题。

  • 使用同步方法,修改sell方法
private synchronized void sell(String name){
  try {
    Thread.sleep(100);
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
  // 当票数大于0时表示有票,执行卖票操作
  if (ticket > 0) {
    System.out.println(name + "卖:" + ticket + "号票");
    ticket--;
  }
}

运行后会发现,使用同步方法的执行速度没有同步代码块的速度快。

  • 使用Lock锁
package com.yizhan.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
 * 功能描述:卖票案例
 *
 * @Author: apple
 * @Date: 2021/10/10 10:16 下午
 */
public class DemoTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();   // 创建卖票实例

        // 创建三个线程
        Thread t1 = new Thread(ticket,"窗口1");
        Thread t2 = new Thread(ticket,"窗口2");
        Thread t3 = new Thread(ticket,"窗口3");

        // 执行线程
        t1.start();
        t2.start();
        t3.start();
    }

    static class Ticket implements Runnable{
        private int ticket = 100;
        Lock lock = new ReentrantLock();
        @Override
        public void run() {
            String name = Thread.currentThread().getName(); // 获取当前线程名称(售票窗口名称)
            // 循环卖票
            while(true){
                sell(name);
                // 当票数为0时 退出循环
                if(ticket <= 0){
                    break;
                }
            }
        }

        /**
         * 卖票
         * @param name 窗口名称
         */
        private void sell(String name){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            lock.lock();
            // 当票数大于0时表示有票,执行卖票操作
            if (ticket > 0) {
                System.out.println(name + "卖:" + ticket + "号票");
                ticket--;
            }
            lock.unlock();
        }
    }
}

在使用到锁的场景中,使用Lock锁会很灵活,可以加入一些逻辑操作对锁进行操作,而使用同步代码块和同步方法,只有等线程执行完毕才会释放锁,无法对锁进行控制。

1.3.3、死锁

多线程死锁:同步中嵌套同步,导致锁无法释放。

死锁解决办法:不要在同步中嵌套同步

  • 死锁问题的产生
package com.yizhan.thread.lock;

/**
 * 功能描述:
 *
 * @Author: apple
 * @Date: 2021/10/11 11:58 上午
 */
public class Demo6DeadLock {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();   // 创建卖票实例
        // 创建三个线程
        Thread t1 = new Thread(ticket,"窗口1");
        Thread t2 = new Thread(ticket,"窗口2");
        Thread t3 = new Thread(ticket,"窗口3");

        // 执行线程
        t1.start();
        t2.start();
        t3.start();
    }

    static class Ticket implements Runnable{
        private int ticket = 100;   // 票数
        Object lock = new Object(); // 定义锁
        @Override
        public void run() {
            String name = Thread.currentThread().getName(); // 获取当前线程名称(售票窗口名称)
            while (true){
                if("窗口1".equals(name)){
                    // 线程一(窗口1)获取到锁——Lock锁
                    synchronized (lock){ // ①
                        sell(name);
                    }
                }else{
                    sell(name);
                }
                if(ticket <= 0)
                    break;
            }
        }

        // 卖票
        private synchronized void sell(String name){    // 线程二(窗口2/3)获取到this锁 ②
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock){                        // 线程二(窗口2/3)获取Lock锁 ③
                if(ticket > 0){
                    System.out.println(name + "卖:" + ticket + "号票");
                    ticket--;
                }
            }
        }
    }
}

执行后会发现出现死锁现象,如果没有出现,多执行几次或者把票数改大点可以观察出死锁的情况。

死锁问题分析:

​ 当线程一(窗口1)执行到32行时获取到Lock锁,假如此时停留在这里,其它线程执行到了44行时获取this明锁,不会出现死锁情况,当执行到50行的时候,要获取Lock锁,由于线程一已经获取到了Lock锁,所以其它线程就获取不了,此时线程一执行到44行时要获取this明锁,由于其它线程获取到了this明锁,所以线程一就获取不了this明锁,最终产生了死锁现象。线程一拿了其它线程的需要的锁,而其它线程拿了线程一需要的锁。

1.3.4、wait()、notify()

wait()、notify()、notifyAll()是三个定义在Object类里的方法,可以用来控制线程的状态。

wait 方法会使持有该对象的线程把该对象的控制权交出去,然后处于等待状态。 notify 方法会通知某个正在等待这个对象的控制权的线程继续运行。 notifyAll 方法会通知所有正在等待这个对象的控制权的线程继续运行。

注意:一定要在线程同步中使用,并且是同一个锁的资源

  • wait和notify方法例子,一个人进站出站:
import java.util.ArrayList;
/**
 * 功能描述:进站出站
 *
 * @Author: apple
 * @Date: 2021/10/11 1:23 下午
 */
public class Demo7WaitAndNotify {
    public static void main(String[] args) {
        State state = new State();

      	// 进站线程
        Thread inThread = new Thread(new InThread(state));
      	// 出站线程
        Thread OutThread = new Thread(new OutThread(state));

        inThread.start();
        OutThread.start();
    }
    static class State{
        public String flag = "车站外";
    }

    static class InThread implements Runnable{
        private State state;
        public InThread(State state) {
            this.state = state;
        }

        @Override
        public void run() {
            while (true){
                synchronized (state){
                    if("车站内".equals(state.flag)){
                        // 在车站内,不用进站,等待
                        try {
                            state.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("进站");
                    state.flag = "车站内";
                    state.notify();
                }
            }
        }
    }

    static class OutThread implements Runnable{
        private State state;

        public OutThread(State state) {
            this.state = state;
        }

        @Override
        public void run() {
            while (true){
                synchronized (state){
                    if("车站外".equals(state.flag)){
                        // 在车站外,不用出站,等待
                        try {
                            state.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("出站");
                    state.flag = "车站外";
                    state.notify();
                }
            }
        }
    }
}
1.3.5、wait()和sleep()区别
  • 对于sleep()方法,首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。

  • sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。

    wait()是把控制权交出去,然后进入等待此对象的等待锁定池处于等待状态,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。

  • 在调用sleep()方法的过程中,线程不会释放对象锁。而当调用wait()方法的时候,线程会放弃对象锁。

1.4、线程停止

结束线程有以下三种方法:

  • 1)设置退出标志,使线程正常退出。
  • 2)使用interrupt()方法中断线程。
  • 3)使用stop方法强行终止线程(不推荐使用Thread.stop, 这种终止线程运行的方法已经被废弃,使用它们是极端不安全的!)
1.4.1、使用标志位退出

一般run()方法执行完,线程就会正常结束,然而,常常有些线程是伺服线程。它们需要长时间的运行,只有在外部某些条件满足的情况下,才能关闭这些线程。使用一个变量来控制循环,例如:最直接的方法就是设一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出

  • 使用标志位结束线程
/**
 * 功能描述:线程退出
 *
 * @Author: apple
 * @Date: 2021/10/11 11:09 下午
 */
public class DemoExit {
    // 线程退出标志位
    public static boolean flag = true;
    public static void main(String[] args) {
        // 创建线程
        new Thread(new Runnable() {
            @Override
            public void run() {
                while(flag){
                    try {
                        Thread.sleep(100l);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("子线程运行");
                }
            }
        }).start();

        try {
            // 主线程休眠1秒
            Thread.sleep(1000l);
            // 修改标志位为false,子线程会停止运行
            flag = false;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
1.4.2、使用interrupt()方法

使用interrupt()方法中断线程有两种情况:

1)线程处于阻塞状态:

​ 线程里面如使用了sleep,同步锁的wait,socket中的receiver,accept等方法时,会使线程处于阻塞状态。当调用线程的interrupt()方法时,会抛出InterruptException异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后break跳出循环状态,从而让我们有机会结束这个线程的执行。

  • 阻塞状态下中断线程
/**
 * 功能描述:线程终止
 *
 * @Author: apple
 * @Date: 2021/10/11 11:16 下午
 */
public class DemoInterrupt {
    public static void main(String[] args) throws InterruptedException {
        // 创建线程
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){
                    try {
                        System.out.println("子线程运行");
                        Thread.sleep(100l); // 调用sleep方法,线程处于堵塞状态
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        // 线程处于阻塞状态,当调用线程的interrupt()方法时,
                        // 会抛出InterruptException异常,跳出循环
                        break;
                    }
                }
            }
        });
        thread.start();

        // 主线程休眠1秒
       Thread.sleep(1000l);
        // 线程中断
        thread.interrupt();
    }
}

​ 主线程休眠了1秒,在1秒之前用户线程可以正常运行,1秒后主线程调用了用户线程的interrupt()方法,而用户线程又调用了sleep方法,造成线程堵塞,此时用户线程就会抛出InterruptException异常,然后再异常里退出循环。

2)线程处于非阻塞状态:

使用isInterrupted()判断线程的中断标志来退出循环。当使用interrupt()方法时,中断标志就会置true,和使用自定义的标志来控制循环是一样的道理。

  • 非阻塞状态下终止线程—修改创建线程的代码
// 创建线程
Thread thread = new Thread(new Runnable() {
  @Override
  public void run() {
    while(true){
      try {
        System.out.println("子线程运行");
        // 判断线程的中断标志位来退出循环
        if(Thread.currentThread().isInterrupted()){
          break;
        }
        //Thread.sleep(100l); // 调用sleep方法,线程处于堵塞状态
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
  }
});

1.5、线程优先级

1.5.1、优先级priority

操作系统基本采用分时的形式调度运行的线程,线程分配得到时间片的多少决定了线程使用处理器资源的多少,也对应了线程优先级这个概念。

在Java线程中,通过一个setPriority()方法来控制优先级,范围为1-10,其中10最高,默认值为5。

/**
 * 功能描述:线程优先级
 *
 * @Author: apple
 * @Date: 2021/10/12 12:36 上午
 */
public class DemoPriority {
    public static void main(String[] args) {
        PrioritytThread prioritytThread = new PrioritytThread();

        // 如果8核CPU处理3线程,无论优先级高低,每个线程都是单独一个CPU执行,就无法体现优先级
        // 开启10个线程,让8个CPU处理,这里线程就需要竞争CPU资源,优先级高的能分配更多的CPU资源
        for (int i = 0; i < 8; i++) {
            Thread t = new Thread(prioritytThread, "线程" + i);
            if (i == 1) {
                t.setPriority(10);
            }
            if (i == 2) {
                t.setPriority(1);
            }
            t.setDaemon(true);
            t.start();
        }

        try {
            Thread.sleep(1000l);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("线程1总计:" + PrioritytThread.count1);
        System.out.println("线程2总计:" + PrioritytThread.count2);
    }

    static class PrioritytThread implements Runnable {
        public static Integer count1 = 0;
        public static Integer count2 = 0;

        public void run() {
            while (true) {
                if ("线程1".equals(Thread.currentThread().getName())) {
                    count1++;
                }
                if ("线程2".equals(Thread.currentThread().getName())) {
                    count2++;
                }

                if (Thread.currentThread().isInterrupted()) {
                    break;
                }
            }
        }
    }
}
1.5.2、join()方法

join作用是让其他线程变为等待。thread.Join把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。比如在主线程中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行主线程。

  • 先运行一段没有使用join()方法的线程
/**
 * 功能描述:join()方法
 *
 * @Author: apple
 * @Date: 2021/10/13 5:14 下午
 */
public class DemoJoin {
    public static void main(String[] args) {
        Thread t1 = new Thread(new JoinThread(),"线程一");
        Thread t2 = new Thread(new JoinThread(),"线程二");
        Thread t3 = new Thread(new JoinThread(),"线程三");
        t1.start();
        t2.start();
        t3.start();

        for(int i=0;i<5;i++){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("主线程内容是:"+i);
        }
    }
    static class JoinThread implements Runnable{
        public void run() {
            String name = Thread.currentThread().getName();
            for(int i=0;i<5;i++){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(name+"内容是:"+i);
            }
        }
    }
}

多运行几次后可以发现主线程和三个用户线程都是交叉运行的

  • 使用join()方法,让线程二加入到主线程中
/**
 * 功能描述:join()方法
 *
 * @Author: apple
 * @Date: 2021/10/13 5:14 下午
 */
public class DemoJoin {
    public static void main(String[] args) {
        Thread t1 = new Thread(new JoinThread(),"线程一");
        Thread t2 = new Thread(new JoinThread(),"线程二");
        Thread t3 = new Thread(new JoinThread(),"线程三");
        t1.start();
        t2.start();
        t3.start();

        try {
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for(int i=0;i<5;i++){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("主线程内容是:"+i);
        }
    }
    static class JoinThread implements Runnable{
        public void run() {
            String name = Thread.currentThread().getName();
            for(int i=0;i<5;i++){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(name+"内容是:"+i);
            }
        }
    }
}

由于在主线程中让线程二加入到了当前的线程中,主线程就会等待线程二执行完才会继续执行,从运行结果看,主线程一直是在其它线程之后才开始运行的。

1.5.3、yield()方法

Thread.yield()方法的作用:

暂停当前正在执行的线程,并执行其他线程。(可能没有效果) yield()让当前正在运行的线程回到可运行状态,以允许具有相同优先级的其他线程获得运行的机会。因此,使用yield()的目的是让具有相同优先级的线程之间能够适当的轮换执行。但是,实际中无法保证yield()达到让步的目的,因为,让步的线程可能被线程调度程序再次选中。

1.6、多线程并发的三个特性

多线程并发开发中,要知道什么是多线程的原子性,可见性和有序性,以避免相关的问题产生。

1.6.1、原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

一个很经典的例子就是银行账户转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

1.6.2、可见性

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

当线程1执行int i = 0这句时,i的初始值0加载到内存中,然后再执行i = 10,那么在内存中i的值变为10了。

如果当线程1执行到int i = 0这句时,此时线程2执行 j = i,它读取i的值并加载到内存中,注意此时内存当中i的值是0,那么就会使得j的值也为0,而不是10。

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

1.6.3、有序性

有序性:程序执行的顺序按照代码的先后顺序执行

int count = 0;
boolean flag = false;
count = 1; //语句1
flag = true; //语句2

以上代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

什么是重排序?一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致

as-if-serial:无论如何重排序,程序最终执行结果和代码顺序执行的结果是一致的。Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语意)

上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?

再看下面一个例子:

int a = 10; //语句1
int b = 2; //语句2
a = a + 3; //语句3
b = a*a; //语句4

这段代码有4个语句,那么可能的一个执行顺序是: 语句2 语句1 语句3 语句4

不可能是这个执行顺序: 语句2 语句1 语句4 语句3

因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。虽然重排序不会影响单个线程内程序执行的结果,但是多线程会有影响

下面看一个例子:

//线程1:
init = false
context = loadContext(); //语句1
init = true; //语句2

//线程2:
while(!init){//如果初始化未完成,等待
  sleep();
}
execute(context);//初始化完成,执行逻辑

​ 上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行execute(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性

要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

2、Java内存可见性

2.1、了解Java内存模型

​ JVM内存结构、Java对象模型和Java内存模型,这就是三个截然不同的概念,而这三个概念很容易混淆。这里详细区别一下,对于JVM内存结构和Java对象模型了解下即可,在多线程里把Java内存模型理解即可,因为Java内存模型定义了多线程的规范。

2.1.1、JVM内存结构

​ Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。

在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域结构如下:
在这里插入图片描述

JVM内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。

2.1.2、Java对象模型

Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。

HotSpot虚拟机中(Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机),设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass对象,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。
在这里插入图片描述

这就是一个简单的Java对象的OOP-Klass模型,即Java对象模型。

2.1.3、Java内存模型

Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

有兴趣详细了解Java内存模型是什么,为什么要有Java内存模型,Java内存模型解决了什么问题,参考:https://www.hollischuang.com/archives/2550。

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念JSR-133: Java Memory Model and Thread Specification中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。

简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
在这里插入图片描述

JMM线程操作内存的基本的规则:

第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写

第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成。

  • 主内存

    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 本地内存

    主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

2.1.4、小结

JVM内存结构,和Java虚拟机的运行时区域有关。 Java对象模型,和Java对象在虚拟机中的表现形式有关。 Java内存模型,和Java的并发编程有关

2.2、内存可见性

2.2.1、内存可见性介绍

可见性说明:一个线程对共享变量值的修改,能够及时的被其他线程看到

共享变量说明:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
    在这里插入图片描述

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

2.2.2、可见性问题

观察内存不可见性的演示:

/**
 * 功能描述:内存不可见演示
 *
 * @Author: apple
 * @Date: 2021/10/17 5:04 下午
 */
public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo jmmDemo = new JmmDemo();
        Thread t = new Thread(jmmDemo);
        t.start();

        // 主线程休眠1秒
        Thread.sleep(100l);
        // 主线程修改flag为false
        jmmDemo.flag = false;
        System.out.println("******************主线程结束******************");
        System.out.println("flag -> "+jmmDemo.flag);
    }

    static class JmmDemo implements Runnable{
        public boolean flag = true;
        @Override
        public void run() {
            System.out.println("===================子线程开始执行===================");
            while (flag){
            }
            System.out.println("===================子线程结束运行===================");
        }
    }
}

在这里插入图片描述

​ 按照main方法的逻辑,把flag设置为false,那么从逻辑上讲,子线程就应该跳出while死循环,因为这个时候条件不成立,但是我们可以看到,程序仍旧执行中,并没有停止。

原因:线程之间的变量是不可见的,因为读取的是副本,没有及时读取到主内存结果。

解决办法:强制线程每次读取该值的时候都去“主内存”中取值

3、synchronized

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。

3.1、解决可见性问题

JMM关于synchronized的两条规定:

线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的最新值刷新到主内存中

线程加锁时(进入同步代码块时):将清空本地内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

修改上述的run()方法,在死循环中添加同步代码块

@Override
public void run() {
  System.out.println("===================子线程开始执行===================");
  while (flag){
    // 加锁,每次从主内存获取最新变量值
    synchronized (this){
    }
  }
  System.out.println("===================子线程结束运行===================");
}

synchronized实现可见性的过程

  1. 获得互斥锁(同步获取锁)
  2. 清空本地内存
  3. 从主内存拷贝变量的最新副本到本地内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存
  6. 释放互斥锁

3.2、锁优化

synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁主要存在四中状态,依次是:

  • 无锁状态;
  • 偏向锁状态;
  • 轻量级锁状态;
  • 重量级锁状态

他们会随着竞争的激烈而逐渐升级。

注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

3.2.1、自旋锁

​ 线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。

​ 自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

​ 自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;

​ 如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

3.2.2、适应自旋锁

​ JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

​ 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

3.2.3、锁消除

​ 为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

​ 如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法;

StringBuffer的append()方法:
在这里插入图片描述

Vector的add()方法:
在这里插入图片描述

3.2.3、锁粗化

​ 在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

​ 在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

​ 锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

3.2.4、偏向锁

轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。而偏向锁只需要检查是否为偏向锁、锁标识为以及ThreadID即可,可以减少不必要的CAS操作(下一篇会讲解到)

3.2.5、轻量级锁

​ 引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。轻量级锁主要使用CAS进行原子操作。

​ 但是对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

3.2.6、重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值