第二章 多线程入门之高级

第二章 多线程入门之高级

第一章 多线程入门之概念
https://blog.csdn.net/qq_41714995/article/details/124749473?spm=1001.2014.3001.5501
第二章 多线程入门之基础
https://blog.csdn.net/qq_41714995/article/details/124758179?spm=1001.2014.3001.5501
第二章 多线程入门之高级



前言

本文主要记录了join方法


一、引入共享变量

1.1 小故事

之前笔者一直对于多线程中共享变量被脏写没有很清晰的认识,但是随着后来学习的不断深入加之某天看到b站上满一航老师对于共享变量被脏写的例子,真的醍醐灌顶现在将其加工之后分享出来。

假设隔壁老王是一个黑心老板,他只有一台电脑让员工工作。小红和小明共同使用这台电脑操作同一个数据,小明负责+1小红负责-1。但是问题来了:小明可能要睡觉啊,上厕所啊,吃饭啊,不能全天工作,小红也是如此。那么老王就觉得这可不行太亏了。干脆这样给小明分配一段时间,给小红分配一段时间那么他们可以趁着没给分配电脑的时间段睡觉、上厕所、吃饭…

正常情况下,小明使用完小红使用,最后的res=0没有任何问题。如下图所示:
在这里插入图片描述

可是有一天小明将res=1之后病倒了,老王看小明病倒了实在不能工作了就告诉小明那你先记着这个res的结果,等你病好了再把res放进去。小红抓紧来干活儿,于是小红继续-1并且将结果放回电脑中,小明病好了之后就继续把之前的res=1放回电脑中。那么大家发现问题了没有,本来两个人操作之后结果应该是0,但是现在的结果却是1。这是不是就出现了共享变量脏写的问题呢?如下图所示:

在这里插入图片描述

1.2 临界区和竞态条件

我们用一段代码举例吧:

 public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }, "线程一");

        Thread thread2 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                count--;
            }
        }, "线程二");

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);

    }
    //运行结果:1570

这段如果对于join()不太清楚可以看我之前的文章

我们使用时序图研究一下上面的问题出现的原因。
对于i++而言会产生下面的Jvm字节码指令

getstatic  i //获取静态变量i的值
iconst_1     //准备常量1
iadd         //自增
putstatic    //将自增的结果存入静态变量i

对于i–而言会产生下面的Jvm字节码指令

getstatic  i //获取静态变量i的值
iconst_1     //准备常量1
isub         //自-
putstatic    //将自增的结果存入静态变量i

作为小白的你可能很难明白上面的字节码指令,我刚刚开始的时候也是非常蒙的,如果你学了Jvm可能会好很多。你可以这样想java作为一门高级语言,计算机底层肯定不能理解的,必须转为更加直白的指令计算机才能理解,Jvm指令便是这个指令。线程执行程序的时候你以为是在执行java代码,但其实执行的是Jvm指令。

在这里插入图片描述
临界区:一段代码中存在着对共享变量的,这段代码成为临界区,上面代码中的count++和count–就是临界区。
竞态条件:通过执行临界区的代码而发生了线程安全的问题就是发生了竞态条件

1.3 学好synchronized

当发生了竞态条件可以使用阻塞式和非阻塞式两种解决方法,阻塞式即意味着加,非阻塞式即意味着原子变量(原子变量小白不用着急懂,慢慢来 ^^) 。锁主要有两种,一种是sychronized,一种是lock。我们今天来学习synchronized。

1.3.1 sychronized的小故事

我们接着上面的例子,老王发现上面那样做可不行,小明没有做完工作小红就继续做公司账目都乱套了。于是他想了一个好办法,小明使用这唯一一台电脑的时候需要加锁,小明必须把工作做完才能将锁打开给小红使用。(这期间老王也会将电脑使用权交给小红,只是电脑被小明锁住了小红不能工作)
在这里插入图片描述

1.3.2 synchronized 的使用方法

代码块
sychronized(对象) {
	代码...
}
方法修饰符
public sychronized f1(){
	代码...
}
相当于
sychronized(this) {
	代码...
}
public static sychronized f2() {
	代码...
}
相当于
sychronized(当前类.class) {//当前类对象只有一份
	代码...
}

如果想让临界区不发生竞态条件必须要保证sychronized加的锁时是一个对象

如果这部分理解的不清楚的话,我们可以搜一下线程八锁
TODO 线程八锁链接http://t.csdn.cn/1gGrg

1.3.3 变量的线程安全分析

成员变量和静态变量是线程安全的吗
  • 如果变量没有共享,那么线程安全
  • 如果变量共享但是只是读操作,那么线程安全
  • 如果变量共享有写操作,那么线程不安全
局部变量是线程安全的吗
  • 局部变量是线程安全的
  • 但是如果有逃逸问题,那么就是线程不安全的

线程逃逸问题TODO

1.3.4 Monitor 概念

Java对象头

普通对象

|-----------------------------------------------------------|
|                    Object Header (64 bits)                |
|---------------------------------|-------------------------|
|             Mark Word (32 bits) | Klass Word (32 bits)    |
|---------------------------------|-------------------------|

数组对象

|------------------------------------------------------------------------------|
|                                 Object Header (96 bits)                	   |			  |                                                                              |
|---------------------------------|--------------------|-----------------------|
|   Mark Word (32 bits)           | Klass Word(32 bits)|  array length(32bit)  |                              
|---------------------------------|--------------------|-----------------------|
|-----------------------------------------------------------------------------------------------------------------|
|                                             Object Header(64bits)                                               |
|-----------------------------------------------------------------------------------------------------------------|
|                       Mark Word(32bits)                           |  Klass Word(32bits)    |      State         |
|-----------------------------------------------------------------------------------------------------------------|
|     hashcode:25                      | age:4 | biased_lock:0 | 01 | OOP to metadata object |      Nomal         |
|-----------------------------------------------------------------------------------------------------------------|
|     thread:23              | epoch:2 | age:4 | biased_lock:1 | 01 | OOP to metadata object |      Biased        |
|-----------------------------------------------------------------------------------------------------------------|
|     ptr_to_lock_record:30                                    | 00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|     ptr_to_heavyweight_monitor:30                            | 10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                                                              | 11 | OOP to metadata object |    Marked for GC   |
|-----------------------------------------------------------------------------------------------------------------|
Monitor原理图

在这里插入图片描述
当我们写出synchronized(obj){}这样的代码的时候,我们便是将obj和monitor对象进行关联,关联的方法是通过obj对象头中的markword。线程一执行临界区代码的时候便会通过obj对象找到monitor对象看一下 owner并没有值,那么owner便会指向线程一,如果此时线程二也过来执行临界区代码,此时发现owner是有值,那么entryList便会指向线程二,直到线程一执行完临界区的代码,线程二才会被唤醒。(如果线程二、线程三都在entryList中,并不是公平的意味着线程二虽然在线程三的前面,但是线程二和线程三也是同时竞争的)

1.3.5 synchronized的高级

轻量级锁
为什么要有轻量级锁?

既然synchronized可以抵
在这里插入图片描述
object对象由markwordklasswordobjectbody这几个部分组成,其中markword包括hashcode、age(分代年龄)、bias(是否偏向锁)、objectbody(记录了成员变量)。

static final object obj = new Object();
public static void method1(){
	synchronized (obj) {
		method1();
	}
}
public static void method1(){
	synchronized(obj) {
	
    }
}

当线程一执行method1()时,会在线程一对应的栈中开辟一块栈帧,这个栈帧中存放着lockrecord,先将obj对象的对头拷贝到lockrecord中,然后会尝试将lock record地址 和obj的markword进行cas交换(原子性),如果obj里面的markword是normal的状态那么就交换成功如下图所示:
在这里插入图片描述
cas失败有两种情况:

  • 其他线程已经持有了轻量级锁,即obj的markword是Lightweight Locked
  • 如果是线程一执行method2时,那么表示锁重入此时会在添加条lock record作为锁重入的计数,此时lock record为null + object Refernce。
轻量级锁膨胀

当线程一已经持有轻量级锁之后,线程二来访问synchronized(obj)线程二与obj对象的对象头使用cas进行交换的时候会失败。此时obj的对象头就会变成HeaveyWeight,此时markword由monitor地址+10组成,monitor对象的owner指针会指向线程一,monitor对象的entryset指针会指向线程二。如下图所示:
在这里插入图片描述
在这里插入图片描述
线程一执行完synchronized的代码块之后,线程一就会进行就会将owner指针设置为为null,然后将obj对象的markword设置回来,再将entryset里面的线程唤醒即可。

轻量级锁自旋

调用monitor对象的过程非常消耗性能,所以线程二可能会先自旋,自旋就是开启一个循环一直尝试进行cas交换,如果自旋一定时间还是没有获得锁的话,才会升级为重量级锁。

偏向锁
为什么要有偏向锁?

其实cas的操作时非常耗时的,线程一同时多次进入synchronized(obj)的时候如果使用轻量级锁那么都是要进行cas操作的,所以偏向锁就应运而生了。如果开启了偏向锁的状态那么obj对象就是biased状态,obj的markword前面都是0最后三位是101。线程一进入synchronized代码块之后经过cas操作markword就变成了线程id+101状态,后面无论线程一进入多少次synchronized代码块都不会进行cas操作,只需要看一下对象头是不是该线程的id即可。此时如果线程二也进入了synchronized(obj)代码块,那么线程二会首先判断obj的markword是不是偏向锁,如果是偏向锁的话接着判断线程一是不是还在,如果不在的话那么使用cas交换将obj对象markword中的线程id改成线程二的,如果线程一还存在那么就会将obj升级为线程一的轻量级锁并且线程二会自旋,规定时间如果还没有自旋成功那么就会升级为重量级锁。

偏向锁的撤销

如果在

1.4 常见线程安全类

1.4.1 常见的线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • HashTable
  • java.util.concurrent
    注意:调用里面的每一个方法可以保证原子性,但是方法的组合不是原子性的,下面这端代码就不能保证线程安全性。
Hashtable table  = new Hashtable();
if(table.get("key") == null) {
	table.put("key",value);
}

1.4.2 不可变类的设计

TODO 其实我也不理解 学完添加

String
Integer
实例分析
    //是否是线程安全的?--->不是
   Map<String,Object> map = new HashMap<>();
    //是否是线程安全的?--->是
   String s1 = "...";
    //是否是线程安全的?--->是
   final String s2 = "...";
    //是否是线程安全的?--->不是
   Date d1 = new Date();
    //是否是线程安全的?--->不是,因为虽然d2不能被改变,但是Date()里面的属性可以被改变
   final Date d2 = new Date();

最后一个例子好好理解一下

public class Myservlet extends HttpServlet {

   private UserService userservice  = new UserService();
   public void doGet(HttpServletRequest request,HttpServletResponse response) {
       userservice.update();
   }
   
}
class UserService{
	//是否是线程安全的?--->不是
   private int count = 0;
   public void update() {
       count++;
   }
}
@Aspect
@Component
public class MyAspect {
	//是否是线程安全的?--->不是
   private long start =0L;
   @Before("execution(* *(..))")
   public void before() {
       start = System.nanoTime();
   }
   @After("execution(* *(..))")
    public void after() {
       Long end = System.nanoTime();
       System.out.println("costTime" + (end-start));
   }
}
public class Myservlet extends HttpServlet {
	//是否安全?--->是
    private UserService userservice  = new UserService();
    public void doGet(HttpServletRequest request,HttpServletResponse response) {
        userservice.update();
    }
    
}
class UserService{
    //是否安全?--->是
   private UserDao userDao  = new UserDao();
   public void update(){
       userDao.update();
   }
}
class UserDao{
    
    public void update(){
        String sql = "update user set ... where ...";
        //局部变量线程是否安全?--->是
        try(Connection connection = DriverManager.getConnection("","","")) {
            
        }catch (Exception e) {
            
        }
    }
}
public class Myservlet extends HttpServlet {
	//是否安全?--->不是
    private UserService userservice  = new UserService();
    public void doGet(HttpServletRequest request,HttpServletResponse response) {
        userservice.update();
    }
    
}
class UserService{
    //是否安全?--->不是
   private UserDao userDao  = new UserDao();
   public void update(){
       userDao.update();
   }
}
class UserDao{
    private Connection conn = null;
    public void update() throws Exception{
        String sql = "update user set ... where ...";
        //局部变量线程是否安全?--->不是
        conn = DriverManager.getConnection("","","");
        conn.close()
    }
}

总结:一个类中包含的成员变量:如果是基本类型,那么我们就看本类中没有直接修改这个成员变量的方法,如果有的话那么就是线程不安全的;如果是引用类型,那么我们就看这个对象里面还有没有成员变量,如果有的话我们在按照上面的分析。
下面还有一种情况没有成员变量,但是还是可能出现线程不安全的问题

public abstract class Test2 {
    public void bar(){

        SimpleDateFormat sdf = new SimpleDateFormat();
        foo(sdf);
    }

     abstract void foo(SimpleDateFormat sdf);

    public static void main(String[] args) {
        new Test2().bar();
    }
}

class Test2Son extends Test2{
    String date = "";
    @Override
    void foo(SimpleDateFormat sdf) {
        new Thread(()->{
            try {
                sdf.parse(date);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

注意开闭原则:比如f1()调用f2(),那么并且传递给f2()一个变量sdf,如果f1()中对于sdf进行修改,同时f2()中新开了一个线程也对于该变量进行修改,那么可能会引起线程不安全的问题。

1.4.3 卖票问题

1.4.4 转账问题

1.5 wait notify

1.5.1 原理

new Thread(()->{
	synchronized(obj) {
		if(count<0) {
			obj.wait();//释放obj这把锁,不占用cpu时间并且进入waitset里面
		}
	}
},"线程一").start();
new Thread(()->{
	synchronized(obj) {
		obj.notify();
		obj.notifyAll();
	}
},"线程二").start()

在这里插入图片描述
解释:线程一获得了cpu的时间片,并且获得了synchronized的锁,此时synchronized里面恰好有一个wait()方法,会将线程一放置在waitset里面,并且会把获得的锁释放掉(其他的线程也是有机会获得这把锁的)也不会占用cpu的时间,此时呢线程二恰好获得了CPU的时间并且获得了synchronized这把锁,调用了notify方法将线程一进行唤醒!
注意:
wait()、notify()、notifyAll()方法都是obj对象的方法
wait()、notify、notifyAll()方法必须写在synchronized代码块里面

线程二必须成为了owner以后才能调用wait()和notify和notifyAll方法

1.5.2 使用

public class TestCorrectPosture {
    static final Object room  = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;

    public static void main(String[] args) throws Exception {
        new Thread(()->{
            synchronized (room){
                if(!hasCigarette) {
                    System.out.println("有烟么?"+hasCigarette+Thread.currentThread().getName());
                    System.out.println("不干活"+Thread.currentThread().getName());
                }
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if(hasCigarette) {
                    System.out.println("有烟么?"+hasCigarette+Thread.currentThread().getName());
                    System.out.println("开始工作哦!"+Thread.currentThread().getName());
                }
            }
        },"小南").start();

        for (int i=0; i<10;i++){
            new Thread(()->{
                synchronized (room) {
                    System.out.println("开始工作啦"+Thread.currentThread().getName());
                }
            },"线程"+i).start();
        }

        Thread.sleep(500);

        hasCigarette=true;

    }
}

结果为:
在这里插入图片描述

上面使用的是sleep方法虽然也可以让线程一阻塞住,但是请注意此时主线程送烟(将hasCigarette设置为true的时候没有在synchronized里面写的,因为sleep方法不会释放锁的);请注意sleep不会占用cpu的时间(如果占用cpu的时间的话,那么中间间隔将是1500ms而不是1000ms);请注意sleep不会释放锁,因为当线程一没有获得烟的时候sleep的时候其他线程都要等到他sleep之后才能工作。

基于上面的缺点,我们可以将代码改成wait(),notify()的形式

public class TestCorrectPosture {
    static final Object room  = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;

    public static void main(String[] args) throws Exception {
        new Thread(()->{
            synchronized (room){
                if(!hasCigarette) {
                    System.out.println("有烟么?"+hasCigarette+Thread.currentThread().getName());
                    System.out.println("不干活"+Thread.currentThread().getName());
                }
                try {
                    room.wait();//小南进入waitset里面并且将锁释放掉
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if(hasCigarette) {
                    System.out.println("有烟么?"+hasCigarette+Thread.currentThread().getName());
                    System.out.println("开始工作哦!"+Thread.currentThread().getName());
                }
            }
        },"小南").start();

        for (int i=0; i<10;i++){
            new Thread(()->{
                synchronized (room) {
                    System.out.println("开始工作啦"+Thread.currentThread().getName());
                }
            },"线程"+i).start();
        }

        synchronized (room){
            hasCigarette=true;
            room.notify();
        }
    }
}

结果为:
在这里插入图片描述

小南首先开启线程,当他发现没有烟的时候进如monitor对象里面的entryset里面,并且释放对象锁。此时主线程与其他10个线程共同抢占cpu的时间,其他线程开始工作,主线程开始给小南送烟并且唤醒小南线程,此时小南线程和所有线程开始竞争,竞争成功之后小南便开始了工作。

public class TestCorrectPosture {
    static final Object room  = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;

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

        Thread t1 = new Thread(() -> {
            synchronized (room) {

                System.out.println("烟送到了么"+hasCigarette);
               if (!hasCigarette) {
                    System.out.println("不干活" + Thread.currentThread().getName());
                    try {
                        room.wait();//小南进入waitset里面并且将锁释放掉
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                if (hasCigarette) {
                    System.out.println("开始工作哦!" + Thread.currentThread().getName());
                }else{
                    System.out.println("不干活" + Thread.currentThread().getName());
                }
            }
        }, "小南");

        t1.start();

        Thread t2 = new Thread(() -> {
            synchronized (room) {

                System.out.println("饭送到了么"+hasTakeout);
                if (!hasTakeout) {
                    System.out.println("不干活" + Thread.currentThread().getName());
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
               if (hasTakeout) {
                    System.out.println("开始工作哦!" + Thread.currentThread().getName());
                }else{
                   System.out.println("不干活" + Thread.currentThread().getName());
               }
            }
        }, "小女");
        t2.start();
        Thread.sleep(1);
        synchronized (room){
            hasTakeout=true;
            room.notifyAll();
            System.out.println("饭来了"+Thread.currentThread().getName());
        }
    }
}

运行结果为:
在这里插入图片描述
首先开启了三个线程,小南线程 小女线程main线程三个线程。然后三个线程开始抢占cpu,首先是小南线程抢占到cpu于是执行了前两行结果,然后是小女线程抢占到cpu于是执行了后两行结果,由于main线程被sleep所以最后抢占到线程,main将hasTaken设置为true,并且将两个线程都唤醒,但是其实我们并不想要唤醒小南线程,所以这就产生了一个虚假唤醒的问题
为了解决虚假唤醒的问题我们可以将代码进行优化将if改成while,那么如果判断条件不满足的时候我们便会又一次将小南线程wait住

public class TestCorrectPosture {
    static final Object room  = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;

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

        Thread t1 = new Thread(() -> {
            synchronized (room) {

                System.out.println("烟送到了么"+hasCigarette);
                while(!hasCigarette) {
                    System.out.println("不干活" + Thread.currentThread().getName());
                    try {
                        room.wait();//小南进入waitset里面并且将锁释放掉
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                if (hasCigarette) {
                    System.out.println("开始工作哦!" + Thread.currentThread().getName());
                }else{
                    System.out.println("没干成活" + Thread.currentThread().getName());
                }
            }
        }, "小南");

        t1.start();

        Thread t2 = new Thread(() -> {
            synchronized (room) {

                System.out.println("饭送到了么"+hasTakeout);
                while (!hasTakeout) {
                    System.out.println("不干活" + Thread.currentThread().getName());
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
               if (hasTakeout) {
                    System.out.println("开始工作哦!" + Thread.currentThread().getName());
                }else{
                   System.out.println("没干成活" + Thread.currentThread().getName());
               }
            }
        }, "小女");
        t2.start();
        Thread.sleep(1);
        synchronized (room){
            hasTakeout=true;
            room.notifyAll();
            System.out.println("饭来了"+Thread.currentThread().getName());
        }
    }
}

结果:
在这里插入图片描述
可以看到线程一阻塞之后,这个程序是不停止的。

1.6 设计模式

1.6.1 保护性暂停模式

1.6.2 生产者消费者模式

1.7 线程状态转换

在这里插入图片描述

1.8 死锁、活锁、饥饿

1.9 ReentrantLock

1.9.1 ReentrantLock的特点

  1. 可中断:线程A获得锁,线程B可以将线程A中断
  2. 可以设置超时时间:线程A获得锁,线程B如果一段时间之后还没有获得锁,那么线程B就放弃争抢锁
  3. 可以设置为公平锁
  4. 支持多个条件变量
  5. 支持多个条件变量(可以使用Condition的条件精准唤醒)

与synchronized一样,都支持可重入

ReentrantLock的特性
ReentrantLock lock = new ReentrantLock();
lock.lock()
try{

}finally{
	lock.unlock();
}

可重入
表示同一个线程可以多次获得同一把锁lock.lock()
可打断
表示一个线程在等待锁的过程中如果进入到了阻塞状态,其他线程可以调用该线程的interrupt方法使得该线程从阻塞状态变回就绪状态lock.lockInterruptibly().
锁超时
if(!lock.tryLock()) return;
if(!lock.tryLock(1,SECONDS))该线程会尝试获得lock锁,如果1s之内获得到锁那么就会返回真,接着执行后面的代码。如果1s之内不能获得到锁那么就会返回false。
公平锁
synchronized是一个不公平锁
ReentrantLock默认不公平可以设置为公平锁
为了解决饥饿问题,公平锁一般没有必要,因为并发度非常低
条件变量
synchronized只有一个条件变量存在虚假唤醒的问题,ReentrantLock有多个条件变量不存在虚假唤醒的问题。

2.0 共享模型值内存

主要研究的是共享变量在多线程之间的可见性和有序性问题
JMM即Java Memory Model ,定义了主存,工作内存等抽象概念,JMM体现在以下几个方面:

  • 原子性:保证指令不会受到上下文切换的影响
  • 可见性:保证指令不会收到cpu缓存的影响
  • 有序性:保证指令不会受到cpu指令并优化的影响

2.1可见性问题

public class Test1{
	static boolean run = true;
	public static void main(String[] args){
		Thread t = new Thread(()->{
			while(run){
				
			}
		}).start();
		sleep(1);
		run = false;//主线程虽然将run置为了false,但是t线程仍然不会停止
	}
}

为什么会出现上面的问题?
在这里插入图片描述
对于主线程和t线程来说两者都要从主内存中读取到run变量的值到自己的工作内存中,但是因为t线程到主内存中多次读取run的值,这样Java中的即时编译器便会做出优化,即直接从t线程的工作内存中读取run的值,这样当mian线程更新了run的值为false之后,t线程读取到的还是自己工作线程中的true。
如何解决?
在run前面加上关键字volatile,每次读取run的时候都是从主内存中读取而不是从自己的工作内存中读取。
Synchronized和volatile的区别


总结

本文主要概述了多线程中共享变量的问题

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值