【多线程的相关内容】

(点击跳转即可哦)

java学习专栏

LeetCode刷题专栏


多线程

多线程的概念

进程和线程

进程:程序在系统中的一次执行过程。

进程是现代操作系统中资源分配(CPU,内存等关键系统资源)的最小单位,不同进程之间是相互独立的


线程:线程是进程中的一个子任务。

线程就是进程中的一个独立任务,同一个进程的所有线程共享进程的资源,线程是操作系统任务执行(系统调度)的基本单位


进程和线程的区别:

  1. 进程是OS资源分配的基本单位,线程是OS系统调度的基本单位
  2. 创建和销毁 进程的开销要远比创建和销毁线程大得多(创建和销毁 一个进程的时间要远比创建和销毁线程大得多),线程更加轻量化
  3. 调度一个线程也远比调度一个进程快得多
  4. 进程包含线程,每个进程至少包含一个线程(主线程)
  5. 进程之间彼此相对独立,不同的进程不会共享内存空间; 同一个进程的线程共享内存空间

JDK提供的线程库实际上就是利用操作系统提供的线程库进行二次封装


Thread类

第一个多线程代码

在之前的代码中,入口都是main(主线程),所有的调用都是从主线程开始的,所有的任务都在主方法中(主线程)中执行。

Java中描述线程这个对象的类 —— java.lang.Thread类线程的核心类。都是通过Thread类来启动一个新的线程。

package threadTest.oneThread;

import java.util.Random;

public class threadOne {
    private static class MyThread extends Thread{
        //run方法就是每个线程的核心执行流程方法,每个线程都是从run()方法开始执行线程
        @Override
        public void run() {
            Random random = new Random();
            while (true){
                //打印当前线程的名称
                System.out.println(Thread.currentThread().getName());
                try{
                    //当前线程随机暂停0~9秒
                    Thread.sleep(random.nextInt(10));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        //创建三个线程
        MyThread m1 = new MyThread();
        MyThread m2 = new MyThread();
        MyThread m3 = new MyThread();
        //启动三个线程
        //启动线程 调用start方法,线程启动之后 会由JVM自动执行 每个线程的run方法
        m1.start();
        m2.start();
        m3.start();
        //主线程
        Random random = new Random();
        while (true){
            //打印当前线程的名称
            System.out.println(Thread.currentThread().getName());
            try{
                //当前线程随机暂停0~9秒
                Thread.sleep(random.nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这四个线程是并发执行的


jconsole —查看当前线程情况

JDK和JRE 相比 最大的区别,提供了很多开发会用到的辅助工具

Jconsole命令:查看当前运行的 JVM 内部的线程情况 -》jconsole.exe


创建线程

创建线程:Java中创建线程一共有4种方式

  1. 继承Thread类,覆写run方法(线程的核心工作任务方法)
  2. 覆写Runnable接口,覆写run方法,不带返回值的接口
  3. 覆写Callable接口,覆写call方法,带返回值的接口
  4. 使用线程池创建线程

1 和 2 ,3 最终启动线程都是需要通过Thread 类的start启动线程


实现Callable接口

Callable就是带返回值的工作方法接口,线程核心方法call方法

FutureTask类接收Callable 的返回值

启动线程将FutureTask类传入Thread启动线程,获取相应call方法的返回值,调用FutureTask 的get方法

package threadTest.oneThread;

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableTest {
    public static void main(String[] args) throws Exception{
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i = 0; i<= 1000; i++){
                    sum += i;
                }
                return sum;
            }
        };
        //接收call方法的返回值 使用FutureTask对象   表示此时FutureTask接收的是指定Callable对象的返回值
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        //Thread类接收 Callable接口 必须通过FutureTask类
        Thread thread = new Thread(futureTask);
        thread.start();
        //get方法会阻塞当前线程,直到call方法执行完毕,才恢复执行
        int result = futureTask.get();
        System.out.println("子线程执行结束,result = " + result);
    }
}

步骤:

  1. Callable接口 的返回值使用FutureTask子类 来接收
  2. Callable接口的对象 最终也通过Thread类的start方法启动线程,向Thread类中传入FutureTask对象(包括了call方法)
  3. 调用FutureTask的get方法的返回值,调用get方法的线程会一直阻塞,直到call方法执行结束,有返回值再继续执行

第一种方法的线程创建与启动
  • 一个子类继承 Thread 类
  • 覆写run方法
  • 产生当前这个子类对象,然后调用start方法启动线程
public class Method1 extends Thread{
    @Override
    public void run() {
        while (true){
            try{
                System.out.println("这是子线程的输出结果~" + Thread.currentThread().getName());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
}

Method1 这个子类 就是一个线程实体,继承Thread类

调用start方法启动线程,是由JVM产生操作系统的线程并启动,到底什么时候真正启动,对于我们来说不可见,也没办法控制。



第二种方法 覆写 Runnable接口,覆写run方法

还是需要创建Thread类的对象,调用start方法来启动线程

//这个实现了Runnable接口的子类,并不是直接的线程对象,只是一个线程的核心工作区
//线程的任务 和 线程实体的关系
public class Method2 implements Runnable{

    @Override
    public void run() {
        System.out.println("这是子线程的输出");
    }
}
public class MethodMain {
    public static void main(String[] args) {
        //创建线程任务对象
        Method2 m2 = new Method2();
        //创建线程对象,将任务对象传入线程对象
        Thread thread = new Thread(m2);
        thread.start();
        System.out.println("主线程" + Thread.currentThread().getName());
    }
}

推荐使用方法2 ,实现Runnable接口更加灵活,子类还能实现别的接口,继承别的类

方法1 的话,只能继承Thread类,单继承局限


关于方法1 和 方法 2 的不同写法

1 使用匿名内部类继承Thread类

public class Method12Other {
    public static void main(String[] args) {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                System.out.println("匿名内部类继承Thread类");
                System.out.println(Thread.currentThread().getName());
            }
        };
        t1.start();
        System.out.println("这是主线程" + Thread.currentThread().getName());
    }
}

2 使用匿名内部类 实现Runnable接口

public static void main(String[] args) {
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("匿名内部类实现Runnable接口");
            System.out.println(Thread.currentThread().getName());
        }
    });
    t2.start();
    System.out.println("这是主线程" + Thread.currentThread().getName());
}

3 Lambda表达式 实现 Runnable 接口

 public static void main(String[] args) {
    Thread t3 = new Thread(() -> System.out.println("Lambda表达式实现Runnable接口"));
    t3.start();
    System.out.println("这是主线程" + Thread.currentThread().getName());
}

多线程最大的应用场景就是把一个大任务拆分为 多个子任务(交给子线程),多个子线程并发执行,提高系统的效率。


Thread类常见方法

无论是继承Thread类还是实现Runnable接口,最终启动线程调用的都是Thread类的start方法

Thread 类是JVM描述线程的类,每个线程都对应唯一的Thread对象

Thread类的所有静态方法都是 在哪个线程中调用的,就生效在哪个线程

构造方法

方法说明
Thread创建线程
Thread(Runnable target)使用Runnable对象创建线程
Thread(String name)创建线程对象,并命名
Thread(Runnable target, String name)使用Runnable对象创建线程,并命名
Thread(ThreadGroup group,Runnable target)线程可以被用来分组管理,分好的组即为线程组

Thread 类的核心属性

属性获取方法
IDgetId()
名称getname()
状态getState()
优先级getPriority()
是否后台线程isDaemon()
是否存活isAlive()
是否被中断isInterrupted()

除了New和Terminated 状态,其余都是存活状态

优先级越高的线程越有可能被CPU优先执行,Java程序只是建议优先级高的线程优先执行,到底执行不执行,OS说了算

启动线程

启动线程调用的是Thread类的start方法

中断线程 -》 线程间通信

中断一个正在执行的线程(run方法还没有结束),普通线程会在run方法执行结束之后自动停止

中断线程有两种方式

a. 通过共享变量进行中断

public class ZDThread {
    private static class Mythread implements Runnable{
        volatile boolean is = false;

        @Override
        public void run() {
            while (!is){
                System.out.println(Thread.currentThread().getName() + "在忙");
                try{
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + "被中断了");
        }
    }

    public static void main(String[] args) throws InterruptedException {
         Mythread mythread = new Mythread();
         Thread thread = new Thread(mythread,"线程1");
        System.out.println("开始执行");
        thread.start();
        Thread.sleep(3000);
        System.out.println("忙完了");
        mythread.is = true;
    }
}

b 使用Thread.interrupted() 静态方法 or Thread对象的成员方法isInterrupted()

Thread类的内部包含了一个属性,当前线程是否被中断属性

public class ZD2Thread {
    private static class MyRunnable implements Runnable{

        @Override
        public void run() {
            //静态方法
//            while (!Thread.interrupted()){
            //成员方法
            while (!Thread.currentThread().isInterrupted()){
                System.out.println(Thread.currentThread().getName()  + "在忙");
                try{
                    Thread.sleep(1000);

                } catch (InterruptedException e) {
                    System.err.println("家人需要我,先休息一会");
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + "快忙完了");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyRunnable zd2Thread = new MyRunnable();
        Thread thread = new Thread(zd2Thread,"线程2");
        System.out.println("zhengzaim");
        thread.start();
        Thread.sleep(3000);
        //中断子线程
        thread.interrupt();
    }
}

线程收到内置的中断通知有两种方式

a. 当线程调用sleep/wait/join等方法时,收到中断通知 thread.interrupt(),就会抛出一个中断异常 InterruptedException,当抛出异常后,当前线程的中断状态会被清除

b. 线程没有调用以上三种方法时,处在正常运行状态,收到中断通知 thread.interrupt(),就只是将线程的中断属性置为true

thread.interrupt() 修改指定线程的状态为中断状态

Thread.interrupted():判断当前线程是否被中断,若中断状态为 true ,清除标志位,还原中断状态为false

Thread.currentThread,isInterrupted() : 判断指定线程对象是否 状态为中断状态,若状态 true,不会清除中断标准


并发:好比一个厨师同时在为几桌客人同时炒菜,出菜的时间差非常短

并行:好比有多个厨师同时为几桌客人炒菜

请说明Thread类中run和start的区别?

作用功能不同:

  1. run方法的作用是描述线程具体要执行的任务;
  2. start方法的作用是真正的去申请系统线程

运行结果不同:

  1. run方法是一个类中的普通方法,主动调用和调用普通方法一样,会顺序执行一次;
  2. start调用方法后, start方法内部会调用Java 本地方法(封装了对系统底层的调用)真正的启动线程,并执行run方法中的代码,run 方法执行完成后线程进入销毁阶段

等待另一个线程 ——线程对象.join()

线程对象.join() : 在哪个线程中调用别的线程对象的join方法,意思就是这个线程要等待另一个线程执行完毕 再继续执行本线程的后续代码

eg: 主线程中调用 thread1.join() 主线程就会进入阻塞状态,直到thread1执行结束,主线程才会继续执行。

方法说明
public void join()等待线程结束(死等,等待别人的线程结束才继续执行)
public void join(long millis)等待线程结束,最多等millis毫秒
public void join(long millis,int nanos)同理,但是精度更高

获取当前正在执行的线程对象

Thread.currentThread() 获取当前正在执行的线程对象 静态方法

休眠当前线程

Thread.sleep(long millis) 在哪个线程调用,就休眠哪个线程


线程的状态

名称说明
NEW新建状态
RUNNABLE下一个状态,可执行状态
BLOCKED等待状态,当前线程暂停执行,等待其他任务或者资源
WAITING等待状态,当前线程暂停执行,等待其他任务或者资源
TIMED_WAITING等待状态,当前线程暂停执行,等待其他任务或者资源
TERMINATEd终止状态,表示当前线程已经执行结束了,可以被销毁

关于thread1.isAlive() 判断当前线程对象是否为存活状态,除了New和Terminated 状态,其余都是存活状态

就绪和运行都是runnable状态

Terminated: 线程的run方法执行完毕,或者抛出异常 不正常执行完毕 都会进入终止状态


三种阻塞状态

TIMED_WAITING : 超时等待,该线程需要等待一段时间之后再恢复执行

BLOCKED : 该线程在等待别的线程释放资源 等待获取锁的对象

WAITING : 等待 被另一个线程唤醒 (搭配 .wait() 等待 .notify() 唤醒 )

package threadTest;

/**
 * 三种阻塞状态
 */
public class BolckedState {
    public static void main(String[] args) {
        Object lock = new Object();
        Thread t1 = new Thread(()->{
            synchronized (lock){
                while (true){
                    try{
                        //因为有锁的存在,所以一直在休眠
                        //此时状态就是 TIMED_WAITING  等待时间到了自动唤醒
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        },"t1");
        t1.start();
        Thread t2 = new Thread(()->{
            synchronized (lock){
                //BLOCKED 等待获取锁的对象
                System.out.println("等到了");
            }
        },"t2");
        t2.start();
    }
}

Thread.yield() 方法

调用yield() 方法的线程会主动让出CPU资源,从运行态 转为就绪态 ,等待被CPU继续调度

到底什么时候让出CPU ,什么时候再次被CPU调度,都是OS调度的,我们无权选择


线程安全问题

package threadTest.safe;

/**
 * 查看多线程场景下的线程安全问题
 */
public class SafeDemo  {
    private static class Coun{
        int count = 0;
        void insert(){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Coun c = new Coun();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 20000; i++) {
                c.insert();
            }
        },"t1");
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 20000; i++) {
                c.insert();
            }
        },"t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(c.count);
    }
}

期望得到的数值是 40000,但是实际输出时,根本达不到40000


什么是线程安全?

线程安全指的是代码若是串行执行和并行执行,结果完全一致,就称为该代码是线程安全的

多个线程穿行执行的结果和并行执行的结果不同,这就称为线程不安全


JMM - Java的内存模型 (和后面JAM部分讲的JVM内存区域划分不是一个概念,JVM实实在在的将内存划分为6大区域), 描述多线程场景下,Java的线性内存(CPU 的高速缓存和寄存器) 和主内存的关系 JMM 中的工作内存实际上是CPU 的高速缓存 和寄存器

Java Memory Model:描述线程的工作内存和主内存的关系

每个线程都有自己的工作内存,每次读取变量(共享变量——类中成员变量,静态变量,常量都属于共享变量,在堆区和方法区存储的变量,不是线程的局部变量),都是先从主内存将变量加载到自己的工作内存, 之后关于此变量的所有操作都是在自己的工作内存中执行,然后写回主内存。


一段代码要保证是 线程安全的 (多线程场景下 和 单线程场景下的运行结果保持一致),需要同时满足以下 三个特性 : 原子性、可见性、防止指令重排


原子性

原子性:该操作对应CPU 的一条指令,这个操作不会被中断,要么全部执行,要么都不执行,不会存在中间状态,这个操作就是一个原子性操作

只能采用上锁等类似方式保证只有一个线程操作

可见性

可见性:一个线程对 共享变量的修改,能够及时的被其他线程看到,这种特性称为 可见性

(保证共享变量的可见性:volatile synchronized(lock) final)

防止指令重排

代码的书写顺序 不一定就是最终 JVM 或者 CPU 的执行顺序


要确保一段代码 的线程安全性,需要同时满足 可见性、原子性 和 防止指令重排

synchronized 一个关键字就可以同时满足 原子性和可见性


解决线程安全问题,就是保证可见性和原子性

是否会导致线程不安全,一定要注意,多个线程是否操作同一个变量


synchronized 关键字

关于synchronized 关键字- 监视器锁 monitor lock (对象锁)

a. 所谓”互斥“,mutex lock

某个线程获取该对象的锁时,其他线程若也要 获取同一个对象的锁,就会处在 阻塞等待状态

当一个方法加上synchronized 关键字,所有进入该对象的线程都需要获取当前 对象的”锁“,获取成功 才能进入,获取失败,就会进入阻塞态


  1. 进入synchronized 代码块 就会尝试执行加锁操作
  2. 退出synchronized代码块,就会释放这个锁

正因为这个方法进行了上锁处理,多个线程 执行 此方法时,其实是排队进入,同一时刻只可能有 一个线程进入这个方法。 保证了线程安全性


在Java内部,每个Java对象都有 一块内存,描述当前对象”锁“的信息,锁的信息 就表示 当前对象被哪个线程持有。

若锁信息没有没有保存线程,则说明该对象 没有被 任何线程持有。

若锁信息保存了线程id,其他线程要获取 该锁,就处在阻塞状态

针对每一把锁,操作系统内部维护了一个等待队列,这个等待队列 不满足先来后到的原则,当有锁释放后,剩下的 线程 重新竞争。


b. synchronized 代码块刷新内存:

线程执行synchronized 代码块的流程

a. 获取对象锁

b. 从主内存 拷贝变量值到工作内存

c. 执行代码

d. 将更改后的值写回主内存

e. 释放对象

a~e 只能有一个线程能执行。其他线程都在等待,b c d 对于其他线程就是天然的可见性 和 原子性


c. 可重入,Java中的线程安全锁都是可重入的(包括Java.concurrent.lock)

那什么是可重入呢?

就是获取到对象锁的线程可以再次加锁,这个操作就称为可重入

synchronized支持线程的可重入:

Java 中每个对象都有一个“对象头” (描述当前对象的信息——当前对象被哪个线程持有,以及一个 “计数器” ——当前对象被上锁的次数)

I. 若线程1 需要进入 当前对象的同步代码块(synchronized),此时 当前对象的对象头没有锁信息,线程1 是第一个获取锁的线程,进入同步代码块,对象头 修改持有线程为 线程1 , 计数器 的值由 0 + 1 = 1. 当线程1 在同步代码块中 再次调用 当前对象的 其他同步方法,可以进入,计数器的值再次加1,说明此时对象锁 被线程1 获取了 两次

synchroned 加锁和解锁是隐式的,JVM 来进行加锁和解锁操作

每当进入一次同步代码块 就会自动上锁,并且计数器+1,

每调用结束一次代码块,就会自动解锁,并且计数器-1

当任意时刻 该对象的计数器为 0,且持有线程为 null,说明该对象 可以被 占有。

II.若线程2 需要进入 当前对象的同步代码块,此时当前对象 的 对象头 持有线程1 且计数器不为 0,线程2 就会进入阻塞状态,一直等到线程1释放锁为止(直到计数器 为 0,才是真正的释放锁 )


  1. synchronized 修饰类中的成员方法,则锁的对象就是当前类的对象。当前这个方法是通过哪个对象调用的,synchronized 就锁的是哪个对象
  2. ysnchronized 修饰类中的静态方法,锁的是当前这个类的class对象(全局唯一,相当于我把这个类锁了)。同一时刻只能有一个线程访问这个方法(无论是几个对象)

任何一个类的.class对象都是全局唯一的,当JVM加载这个类的时候产生,描述该类的核心信息(具备哪些属性和方法),这个对象是反射的核心对象。

结果:产生互斥,因为对静态方法加锁,实际上是对类加锁,类只有一个。因此当一个同步静态方法被访问时,该类已处于被锁状态。此时其他同步静态方法不能被访问(未用synchronized修饰的静态方法仍可以访问

  1. synchronized 代码块,明确锁的是哪个对象。锁的粒度更细,只有在 需要同步的若干代码上加上synchronied 关键字
synchronized (具体看里面的对象){
//....
}

到底互斥与否,就看多个线程锁的是什么?只有锁的是同一个对象才互斥,不同对象就不互斥


Java标准库中的线程安全类

Java标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。

线程不安全的类(多线程并发修改同一个集合的内容,就有数据安全问题):

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

当然肯定还有线程安全的类,使用了一些锁机制来控制

  • Vector

  • HashTable

这两个不推荐使用,因为所有的读写操作都是单线程的

  • ConcurrentHashMap 锁的是哈希桶对象
  • StringBuffer
  • CopyOnWriteArrayList 线程安全的List集合,采用读写分离的机制,多线程并发读,互斥写

volatile关键字

volatile : 可见性,内存屏障

  1. volatile 关键字 可以保证共享变量可见性,强制线程读写主内存的变量值 相较于普通的共享变量,使用volatile关键字 可以保证共享变量的可见性

a. 当线程读取的是volatile关键字的变量时,线程直接从主内存中读取该值到工作内存中(无论当前工作内存中是否已经有该值)

b. 当线程写的是volatile 关键字的变量,将当前修改后的变量值(工作内存中)立即刷新到主内存中,且其他正在读此变量的线程会等待,直到写回主内存操作完成,保证读的一定是刷新后的主内存值。

对于同一个volatileb变量,写操作一定发生在他的读操作之前,保证读到的数据一定是主内存中刷新的数据。

package threadTest.safe;

import java.util.Scanner;

public class VolatileTest {
    private static  class Counter{
        volatile int fig = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            while (counter.fig == 0){
//                System.out.println("循环中...");
            }
            System.out.println("退出循环");
        },"t1");
        t1.start();
        Thread t2 = new Thread(()->{
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入修改后的fig的值: ");
            counter.fig = sc.nextInt();
        },"t2");
        t2.start();
    }
}

volatile只能保证可见性,无法保证原子性,因此若代码不是原子性操作,仍然不是线程安全

  1. 使用volatile 修饰的变量,相当于一个内存屏障。
class{
    int x = 1;
    int y = 2;
    volatile boolean = true; 
    x = x+y;
    y = x+3;
}

指令重排:CPU在不影响结果的前提下执行时,不一定按照书写的顺序执行

当使用volatile关键字修饰时, volatile之前的代码执行完毕后,才可以执行volatile关键字之后的代码


wait() 和 notify() 方法

线程间等待与唤醒机制。 wait 和 notify是Object类的方法,用于线程的等待与唤醒,必须搭配synchronized锁来使用。

wait()

多线程并发的场景下,有时需要某些线程先执行,这些线程执行结束后其他线程在继续执行

等待方法:

public final void wait() throw InterruptedException{
 wait(0);
}

痴汉方法,死等,线程进入阻塞态(WAITING),直到有其他线程调用notify()方法唤醒

public final native void wait(long timeout) throw InterruptedException;

等待一段时间,若在该时间内线程被唤醒,则继续执行。

​ 若超过相应时间还没有其他线程唤醒此线程,此线程就不再等待,恢复执行

调用wait方法的前提是 首先要获取该对象的锁(synchronized对象锁),调用wait方法 会释放锁,本线程进入等待队列 等待唤醒,被唤醒之后不是 立即恢复执行,进入阻塞队列,竞争锁

notify方法

notify() : 随机唤醒一个处在等待状态的线程

notifyAll() : 唤醒所有处在等待状态的线程

无论是wait还是notify方法,都需要搭配synchronized锁来使用(等待和唤醒,也是需要对象)

唤醒方法出了同步代码块 才能够 完成唤醒过程

package threadTest.wait_notify;

public class Wait_Notify {
    private static class WaitTask implements Runnable {
        private Object lock;

        public WaitTask(Object lock) {
            this.lock = lock;
        }

        @Override
        public void run() {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + "准备进入等待状态");
                //此线程在等待 lock 对象的notify方法唤醒
                try {
                    lock.wait();
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName() + "等待结束,本线程继续执行");
            }
        }
    }
    private static class NotifyTask implements Runnable{
        private Object lock;
        public NotifyTask(Object lock){
            this.lock = lock;
        }
        @Override
        public void run() {
            synchronized(lock){
                System.out.println("准备唤醒");
                //随机唤醒一个处在lock对象上等待的线程
                lock.notifyAll();
                System.out.println("唤醒结束");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException{
        Object lock = new Object();
        Object lock1 = new Object();
        //创建三个等待线程
        Thread t1 = new Thread(new WaitTask(lock),"t1");
        Thread t2 = new Thread(new WaitTask(lock1),"t2");
        Thread t3 = new Thread(new WaitTask(lock1),"t3");
        //创建一个唤醒线程
        Thread notify = new Thread(new NotifyTask(lock),"notify线程");
        t1.start();
        t2.start();
        t3.start();
        Thread.sleep(100);
        notify.start();
        //当前正在执行的线程数
        Thread.sleep(2000);
        System.out.println(Thread.activeCount() -1); //后台线程
    }
}

对于wait 和 notify 方法,其实有一个阻塞队列,也有一个等待队列。

阻塞队列 表示同一时间只有 一个线程能获取到锁,其他线程进入阻塞队列

等待队列 表示 调用wait (首先此线程要获取到锁, 进入等待队列,释放锁)
在这里插入图片描述

wait() 和 sleep() 方法的区别?

1 wait() 方法是Object类提供的方法,需要搭配synchroneized锁来使用,调用wait()方法会释放锁,线程进入WAITING状态,等待被其他线程唤醒或者超时自动唤醒,唤醒之后的线程需要再次竞争synchronized锁 才能继续执行。

2 sleep方法是Thread类提供的方法,调用sleep方法的线程进入TIMED_WAITING状态,若在同步块中调用slepp方法,不会释放锁,时间到自动唤醒


单例模式

所谓的单例模式 保证某个类在程序中有且只有一个对象。

饿汉式单例

如何控制某个类只有一个对象呢?

1 要创建类的对象,通过构造方法产生对象

2 构造方法若是public 权限,对于类的外部,随意创建对象,无法控制对象的个数

3 但是 若 构造方法私有化,类的外部彻底没法产生对象,一个对象都没有

4 构造方法私有化之后,对于类的外部而言,就一个对象都没有,如何构造唯一的对象(私有化的构造方法只能在类的内部调用),只调用一次构造方法即可

public class SingleTon{ //饿汉式单例 
private static SingleTon singleTon = new SingleTon();
private SingleTon(){}
public static SingleTon getSingleTon(){
  return singleTon;
}
}

在SingleTon类的外部 访问这个唯一的对象,直接通过getSingleTon方法获取这个唯一的对象。

饿汉式单例: 天然线程安全,系统初始化JVM加载类的过程中就创建了这个唯一的对象。(也不管外部是否调用该对象)

三步走:

  1. 构造私有化(保证对象的产生个数)
  2. 单例类的内部提供这个唯一的对象(static)
  3. 单例类提供返回这个唯一的对象的静态方法供外部使用

懒汉式单例

懒汉式单例:只有在 第一次调用getSingleTon方法,表示外部需要获取这个单例对象时,才产生对象。

public class LazySingleTon {
    //默认为null
    private static LazySingleTon singleTon;
    private LazySingleTon(){}
    //第一次调用获取单例对象时 才实例化对象
    public static LazySingleTon getSingleTon(){
        if(singleTon == null){
            singleTon = new LazySingleTon();
        }
        return singleTon;
    }
}
//此时 在多线程场景下是不能保证只有一个对象产生, 比如 三个线程并行调用get方法,此时singleTon 三个线程看到的都是null,每个线程创建了一个对象。所以要实现线程安全,需要加锁

特点:系统在初始化时,外部不需要这个单例对象,就先不产生,只有当外部需要此对象 才实例化对象。 这种操作称为 懒加载


加上锁后的懒汉式单例:

package threadTest.singleTon;

public class LazySingleTon {
    //默认为null
    //双重加锁,使用volatile 关键字 保证单例对象的初始化 不被中断
    private static volatile LazySingleTon singleTon;
    private LazySingleTon(){}
    //第一次调用获取单例对象时 才实例化对象
    public static LazySingleTon getSingleTon(){
        if(singleTon == null){
            synchronized (LazySingleTon.class){
                if(singleTon == null){
                    //此处需要再次判断是否为null
                    singleTon = new LazySingleTon();
                }
            }
        }
        return singleTon;
    }
}
 

同步代码块内部需要再次坚持singleTon 是否为空,防止其他线程恢复执行后多次创建单例对象。

此时使用volatile 修饰单例对象,new 操作这儿 就好比有堵墙,其他线程要能执行到return 操作,JVM就一定保证new 操作完全结束之后,才能执行到return 语句


阻塞队列

阻塞队列是一种特殊的队列,也遵守“先进先出”的原则

阻塞队列是一种线程安全的数据结构,并具有以下特性:

  • 当队列 已满 的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素
  • 当队列 已空 的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素

JDK的阻塞队列 BlockingQueue(是一个接口),入队方法 put() 阻塞式入队方法, 出队方法take() 阻塞式的出队

常用子类:

ArrayBlockingQueue

LinkedBlockingQueue

阻塞队列的实现: 通过锁 + wait 和notify机制实现阻塞队列

  1. 通过“循环队列” 的方式来实现
  2. 使用synchronized进行加锁控制
  3. put插入元素的时候,判定如果队列满了,就进行wait方法(注意,要在循环中进行wait,被唤醒时,不一定队列就不满了,因为可能同时唤醒了多个线程)
  4. take 取出元素的时候,判定如果队列为空就进行wait(也是循环wait)
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(3);

阻塞队列的大小一般通过 构造方法传入,此时就是 3 。若无参就是 无界队列


定时器

JDK中使用Timer类描述定时器,核心方法就是

    public void schedule(TimerTask task, long delay) 
    public void schedule(指定时间到了要执行的任务, 等待时间-ms) 
import java.util.Timer;
import java.util.TimerTask;

public class TimerTest {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello java");
            }
        },3000);//延时3S后开始执行任务
    }
}
    public void schedule(TimerTask task, long delay, long period)
    public void schedule(指定时间到了要执行的任务, 等待时间-ms,间隔时间-ms) 
import java.util.Timer;
import java.util.TimerTask;

public class TimerTest {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello java");
            }
        },3000,1000);//延时3S后开始执行任务,该任务启动之后 每隔 1s 就会再次执行
    }
}

线程池

线程池概念

之前创建线程的两种方式:继承Thread类、实现Runnable接口

最终启动线程都是通过Thread类的start方法。调用Thread类的构造方法产生Thread对象后,线程的状态是? NEW状态

所谓的NEW状态就是 操作系统要准备重新开启一个线程。

调用start方法后,线程的状态是? runnable

启动线程之后,JVM调用线程的run方法来执行线程任务,当run方法执行结束后,线程的状态? Terminated

虽然创建和销毁线程 开销比较小 (和线程相比),但是当系统中线程数量变多时,这个开销就比较可观了

“池” : 目的就是让某些对象被多次利用,减少频繁创建和销毁对象带来的开销问题(这些对象一定是可以复用的)

数据库连接池,创建和销毁 数据库 的连接就是 一个比较耗时的操作,每当一个连接调用close方法终止后,表示当前用户不再使用此链接,就回收连接到 连接池中(同一个连接可以被多个用户使用多次,减少了每次创建连接和销毁连接的系统开销)

不同线程只是run方法的不同,线程的大致流程都是一样的,因此为了避免重复创建和销毁线程带来的开销,可以让线程”复用“起来。

线程池:内部创建好了若干个线程,这些线程都是runnable,只需要从系统中取出任务(run),就可以立即执行。

线程池最大的好处就是 减少每次启动和销毁线程的损耗(提高时间和空间利用率)


线程池的使用

描述线程池的核心类,最常用的一个子类- TheadPoolExecutor 这个类的构造方法就是创建一个线程池的所有核心参数。

线程池的核心父接口 ExecutorService接口

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();

执行任务,线程接收一个runnable对象并执行任务

<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);

提交一个任务(线程的run方法或call方法)到线程池,线程池就会派遣空闲的线程执行任务


void shutdown();

停止所有处在空闲状态的线程,其他正在执行任务的线程,等待任务执行结束再停止

List<Runnable> shutdownNow();

立即尝试终止所有线程池中的线程(无论是否空闲)

都是终止线程池中的所有线程,销毁线程池


创建线程池

Executors -> 线程池的工具类

使用这个类就可以创建JDK内置的四大线程池

package threadTest.threadpool;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ThreadPoolTest {
    public static void main(String[] args) {
        //固定大小线程池
        ExecutorService pool = Executors.newFixedThreadPool(10);
        //数量动态变化的缓存池
        ExecutorService pool1 = Executors.newCachedThreadPool();
        //只包含一个线程的单线程池
        ExecutorService pool2 = Executors.newSingleThreadExecutor();
        //定期线程池,可以设置任务的延时启动世界(Timer类的线程池实现)
        //定时器线程池
        ScheduledExecutorService pool3 = Executors.newScheduledThreadPool(10);
        pool.submit(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                        System.out.println(Thread.currentThread().getName() + " hello~");}
                }
        });
        pool3.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "3s后执行此任务");
            }
        },3, TimeUnit.SECONDS);//第二个参数是延迟时间,第三个参数代表时间的单位
    }
} 	


ThreadPoolExector子类的核心构造方法参数

public ThreadPoolExecutor(int corePoolSize, //核心池的线程数量 - 正式员工数
                          int maximumPoolSize,//线程池的最大线程数 - 正式工+临时工
                          long keepAliveTime,
                          TimeUnit unit,//临时线程空闲 以unit为单位 空闲keepAliveTime时间后销毁
                          BlockingQueue<Runnable> workQueue,//阻塞队列,任务都在队列中存储,线程从队列中取出要执行的任务
                          RejectedExecutionHandler handler)//拒绝策略,当任务数量超出线程的负荷该咋办? AbortPolicy:超出负荷的任务直接拒绝,抛出异常
    			 CallerRunsPolicy:返回给线程池的调用着处理
                 DiscardOldestPolicy():丢弃队列中最老的任务(排队时间最长的任务)
                 DisCardPolicy(): 丢弃新来的任务

固定大小核心线程池

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, //固定大小线程池
    								nThreads,//最大线程数
                                  0L, TimeUnit.MILLISECONDS,//因为没有临时线程,所以没有临时线程
                                  new LinkedBlockingQueue<Runnable>());//基于链表的阻塞队列,无界队列
}

数量动态变化的缓存池

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,//没有正式员工,全是临时工
                                  60L, TimeUnit.SECONDS,//当临时工空闲60s 就销毁
                                  new SynchronousQueue<Runnable>());//一个元素都保存不了,take 元素必须等另一个线程put
}

单线程池

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,//只有一个线程,所以也没有临时线程
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));//基于链表实现的无界阻塞队列
}

单线程池有意义么?和我们只创建一个线程区别在哪

这个线程只能执行一个任务,一个任务执行完了就销毁了

单线程池:虽然同一时间只能执行一个任务,当这个任务执行结束之后,继续在工作队列调度一个新的任务继续执行

定时器线程池

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}
    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, //核心线程
        Integer.MAX_VALUE,//最大线程数(正式+临时)
        0, NANOSECONDS,//临时线程一空闲就销毁
              new DelayedWorkQueue());//根据延迟时间执行任务队列
    }

ExecutorService

线程池的核心接口

方法:

submit(runnable | callable) 提交任务

shutdown() 终止空闲线程,有任务线程,等待任务结束再终止

shutdownNow() 立即终止所有线程

ScheduledExecutorServiceExecutorService 的 子接口

定时器线程池接口

拓展了一个可以延迟启动任务的方法

public ScheduledFuture<?> schedule(Runnable command,
                                 long delay, TimeUnit unit);

以unit为单位,延迟delay时间后开始执行command任务

 public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                               long initialDelay,
                                               long period,
                                               TimeUnit unit);

以unit为单位,延迟initialDelay时间后执行任务,每间隔period时间就执行一次任务

ThreadPoolExector子类 - 线程池接口的最常用子类

Executors ——线程池的工具类,内置了四大创建好的线程池(创建4个ThreadPoolExector对象)


线程池工作流程

在这里插入图片描述


常见锁的策略

所谓锁的策略就是指到底如何实现锁

乐观锁和悲观锁

乐观锁:每次读写数据都认为不会发生冲突,线程不会阻塞,一般来说,只有在进行数据更新时才会检查是否发生冲突,若没有冲突,直接更新,只有冲突(多个线程都在更新数据)了才解决问题。

应用场景:当线程冲突不严重的时候,可以采用乐观锁策略 来 避免多次的加锁解锁策略


悲观锁:每次去读写数据都会冲突,每次在进行数据读写时 都会上锁(互斥),保证同一时间段只有一个线程在读写数据

应用场景:当线程冲突严重时,就需要加锁,来避免线程频繁范围共享数据失败带来的CPU转存问题


读写锁

多线程范围数据时,并发读取数据不会有线程安全问题,只有在更新数据(增删改)时才会有线程安全问题。将锁分为读锁和写锁

  1. 多线程并发访问读锁(读数据),则多个线程都能访问读锁,读锁和 读锁之间是并发的,不互斥
  2. 两个线程都需要访问写锁(写数据),则这两个线程互斥,只有一个线程能成功获取到写锁,其他写线程阻塞
  3. 当一个线程读,另一个线程写 (也互斥,只有当写线程结束,读线程才能继续执行)

适合在线程基本都在读数据,很少有写数据的情况

synchronized不是读写锁,JDK内置了 ReentrantReadWriteLock实现读写锁


重量级锁和轻量级锁

重量级锁:需要操作系统和硬件支持,线程获取重量级锁失败 进入 阻塞状态(OS,用户态 切换到 内核态,开销特别大)

轻量级锁:尽量在用户态执行操作,线程不阻塞,不会进行状态切换

轻量级锁的常用实现方式就是采用自旋锁

自旋锁:自旋就是循环,当线程获取锁失败并不会让出CPU,线程也不会阻塞,不会从用户态切换到内核态,线程在CPU上空跑,当锁被释放,此时这个线程会很快获取到锁

公平锁和非公平锁

公平锁:获取锁失败的线程进入阻塞队列,当锁被释放,第一个进入队列的线程首先获取到锁(等待时间最长的线程 获取到锁)

非公平锁:获取锁失败的线程进入阻塞队列,当锁被释放,所有在队列中的线程都有机会获取到锁,获取到锁的线程不一定就是等待时间最长的线程

synchronized 就是非公平锁


CAS锁

CAS:Compare and Swap 比较交换, 不会真正阻塞线程,不断尝试更新

假设当前主内存中的值为V, 工作内存中的值为A, 当前线程要修改的值为B

CAS的操作流程如下:

  1. 比较内存中的值V 和 当前工作内存的值A 是否相等
  2. 若相等,可以认为 当前主内存的值没有修改,当前线程就将B写回主内存
  3. 若不相等,说明当前线程的值A已经过时了(主内存已经发生了变化),将当前内存的最新值V 保存到当前工作内存,此时无法将B写回主内存,继续循环

CAS 是怎么实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;

unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;

Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子 性。

简而言之,是因为硬件予以了支持,软件层面才能做


CAS的应用

  1. 实现原子类

java.util.concurrent(juc包,也是并发工具包),原子类,线程安全集合,ConcurrentHashMap,CopyOnWriteArrayList

​ int i = 0; i++; i–; 都是非原子性操作,多线程并发会有线程安全问题

使用原子类来保证他的线程安全性

AtomicInteger i = new AtomicInteger();//无参构造,默认 i 为0
AtomicInteger i = new AtomicInteger(10); //有参构造,从 10 开始
//i++
i.getAndIncrement();
//++i
i.incrementAndGet();
//--i
i.decrementAndGet();

2.使用CAS锁来实现自旋锁

自旋锁就是指 获取锁失败的线程不进入阻塞态,而是在CPU上空转(线程不让出CPU,而是跑一些无用指令),不断的查询当前锁的状态

自旋锁的伪代码

public class SpinLock {
 private Thread owner = null;//获取锁的线程
 public void lock(){
     // 通过 CAS 看当前锁是否被某个线程持有. 
     // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
     // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
     while(!CAS(this.owner, null, Thread.currentThread())){
    }
}
 public void unlock (){
     this.owner = null;
}
}

CAS(this.owner, null, Thread.currentThread()

this.owner 表示当前获取锁的线程

null 期望获取锁的线程为 null,当前自旋锁没有被任何线程持有

Thead.currentThread() 当前正在尝试获取锁的线程

当this.owner == null,当前自旋锁没有被任何线程持有 -》 尝试将this.owner = Thread.currentThread(),将持有锁的线程设置为当前线程

  1. CAS引发的ABA问题

ABA问题:

假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.

接下来, 线程 t1 想使用 CAS num 值改成 Z, 那么就需要 先读取 num 的值, 记录到 oldNum 变量中 ,使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z. 但是, 在t1执行这两个操作之间, t2线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A

在这里插入图片描述

解决ABA问题?

引入版本号,只有当CAS V == A 时,才会用到版本号,来判断当前主内存的V是否被其他线程反复修改过了


synchronized关键字背后的锁升级的流程

使用的synchronized关键字,背后到底是什么锁,JVM来进行处理,会根据竞争的激烈程度 动态选择锁的实现

在这里插入图片描述


juc.lock(对象锁)

Juc下的常用子类

  1. 对象锁 juc.lock

在Java中 除了synchronized 关键字 可以实现对象锁之外

JDK1.0就有的,需要JVM借助操作系统提供的mutex系统原语实现

java.util.concurrent中的Lock接口也可以实现对象锁

JDK1.5之后,Java语言自己实现的互斥锁,不需要借助操作系统的monitor机制

void lock();  //加锁,获取锁失败的线程进入阻塞状态,直到其他线程释放锁,再次竞争,死等
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//加锁,获取锁失败的线程进入阻塞态,等待一段时间,时间过了若还未恢复执行,放弃加锁,执行其他代码
void unlock(); //解锁

看代码:

package threadTest.lockPackage;

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

public class LockTest {
    public static void main(String[] args) {
        //传入true就变成了一个公平锁
        ReentrantLock lock = new ReentrantLock(true);
        Thread thread = new Thread(()->{
            lock.lock();//代码从这里开始加锁,直到碰到unlock解锁
            //互斥代码块
            try{
                System.out.println(Thread.currentThread().getName()+"获取到锁");
                Thread.sleep(8000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();//代码从这里解锁
            }
            System.out.println(Thread.currentThread().getName()+"释放锁");
        },"t1");
        thread.start();

        Thread thread1 = new Thread(()->{
            boolean isLock = false;
            try{
                isLock = lock.tryLock(1000, TimeUnit.MILLISECONDS);
                System.out.println(Thread.currentThread().getName() + "1 执行");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {

                if(isLock){//只有获取锁成功的线程才需要执行unlock方法
                    System.out.println(Thread.currentThread().getName() + "获取到锁");
                    lock.unlock();
                }
            }
            System.out.println(Thread.currentThread().getName() + "线程结束");
        },"t2");
        thread1.start();
    }
}
synchronized和lock的区别
  1. synchronized 是 Java的关键字,由JVM实现,需要依赖操作系统提供的线程互斥原语(mutex);Lock标准库的类和接口,其中一个最常用的子类(ReentrantLock,可重入锁),由Java本身实现的,不需要依赖操作系统

  2. synchronized 隐式的加锁和解锁,lock 可以使用tryLock等待一段时间之后,自动放弃加锁,线程恢复执行

  3. synchronized 在获取锁失败的线程时,死等;lock可以使用tryLock等待一段时间后自动放弃加锁,线程恢复执行

  4. synchronized是非公平锁,ReentrantLock默认是非公平锁,可以在构造方法中传入true开启公平锁。

ReentrantLock lock = new ReentrantLock(true);
  1. synchronized 不支持读写锁,Lock子类ReentrandWriteLock支持读写锁

死锁

锁对象等待问题,可以使用jconsole 来检查死锁

如何避免死锁

死锁产生的四个必要条件:

互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用

不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。

循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路。

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让 死锁消失

破坏循环等待 最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3…M). N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待.


要是对大家有所帮助的话,请帮我点个赞吧。

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值