Java 并发编程三大特性--volatile

面试题:
1、请谈谈你对volatile的理解?
volatile是Java虚拟机提供的轻量级的同步机制
    1.保证可见性
    2.不保证原子性
    3.禁止指令重排
2、JMM你谈谈?
    Java内存模型 
3、你在哪些地方用到过volatile?
    单例模式
    CAS底层代码

目录

一、并发编程的三大特性

 1、可见性

1.1、解决可见性的方式

    1、加volatile解决

    2、加synchronized解决

     3、加Lock

    4、final

2、原子性

2.1、原子性指的是什么意思?

2.2、volatile不保证原子性

2.3、如何保证原子性?

        1、加synchronized

        2、使用JUC下的AtomicInteger解决

        3、Lock锁

        4、ThreadLocal

3、有序性

3.1、什么是有序性

3.2、as-if-serial

3.3、happens-before

3.4、volatile

4、总结


一、并发编程的三大特性

    JMM(Java内存模型 Java Memory Model,简称JMM) 本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构造数组对象的元素)的访问方式。

    不同的硬件和不同的操作系统在内存上的操作有一定差异的。Java为了解决相同代码在不同操作系统上出现的各种问题,用JMM屏蔽掉各种硬件和操作系统带来的差异

    让Java的并发编程可以做到跨平台。

    JMM规定所有变量都会存储在主内存中,在操作的时候,需要从主内存中复制一份到线程内存(CPU内存),在线程内部做计算。然后再写回主内存中(不一定!)。

JMM关于同步的规定:

      1、线程解锁前,必须把共享变量的值刷新回主内存

      2、线程加锁前,必须读取主内存的最新值到自己的工作内存

      3、加锁解锁是同一把锁

主内存:内存8G硬件内存条
自己的工作内存:各自线程的工作内存

工作速率:硬盘<内存<CPU
CPU与内存之间读取有一个缓存cache;
CPU计算数据计算完,但是内存数据还没拿到,此时CPU干等着?
    CPU和内存之间有一个缓存来临时存储数据。
可以通过CPU-Z软件来查看,有一个缓存工具栏。

    由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

 1、可见性

    可见性问题是基于CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,CPU就提供了L1,L2,L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。
    这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题。

    这种第一时间通知内存修改的消息机制,就叫Java内存模型中的可见性。可以理解为及时通知。

可见性的代码验证

import java.util.concurrent.TimeUnit;

public class VolatileDemo {

	public static void main(String[] args) {
		MyData myData = new MyData();
		new Thread(() -> {
			System.out.println(Thread.currentThread().getName() + "\t in");
			try {
				TimeUnit.SECONDS.sleep(3);
			} catch (Exception e) {
				e.printStackTrace();
			}
			myData.add();
			System.out.println(Thread.currentThread().getName() + "\t over ,num=" + myData.num);
		}, "aa").start();

		while (myData.num == 0) {
			// main线程一直在此等待,直到num不等于0
		}
		System.out.println(Thread.currentThread().getName() + "\t over");
	}

}

class MyData {
	int num = 0;

	public void add() {
		this.num = 60;
	}
}

结果如下并且main线程一直没结束,没人通知它num值变更了。

aa     in
aa     over ,num=60

1.1、解决可见性的方式

    1、加volatile解决

    volatile是一个关键字,用来修饰成员变量。
如果属性被volatile修饰,相当于会告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作

volatile的内存语义:

  1. volatile属性被写:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中
  2. volatile属性被读:当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量

    其实加了volatile就是告知CPU,对当前属性的读写操作,不允许使用CPU缓存,加了volatile修饰的属性,会在转为汇编之后后,追加一个lock的前缀,CPU执行这个指令时,如果带有lock前缀会做两个事情:

    1、将当前处理器缓存行的数据写回到主内存。

     2、这个写回的数据,在其他的CPU内核的缓存中,直接无效。

    总结:volatile就是让CPU每次操作这个数据时,必须立即同步到主内存,以及从主内存读取数据。

修改程序,增加volatile:

import java.util.concurrent.TimeUnit;

public class VolatileDemo {

	public static void main(String[] args) {
		MyData myData = new MyData();
		new Thread(() -> {
			System.out.println(Thread.currentThread().getName() + "\t in");
			try {
				TimeUnit.SECONDS.sleep(3);
			} catch (Exception e) {
				e.printStackTrace();
			}
			myData.add();
			System.out.println(Thread.currentThread().getName() + "\t over ,num=" + myData.num);
		}, "aa").start();

		while (myData.num == 0) {
			// main线程一直在此等待,直到num不等于0
		}
		System.out.println(Thread.currentThread().getName() + "\t over ,num=" + myData.num);
	}

}

class MyData {
	volatile int num = 0;

	public void add() {
		this.num = 60;
	}
}

此时结果:

aa	 in
aa	 over ,num=60
main	 over ,num=60

线程aa中把num值变更了,及时通知主线程main,此为JMM的可见性。

    通过前面对JMM介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的。

    这就可能存在一个线程aa修改了共享变量X的值还未写回主内存时,另外一个线程bb又对主内存中同一个共享变量X进行操作,但此时aa线程工作内存中的共享变量X对bb来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题

    2、加synchronized解决

    synchronized也是可以解决可见性问题的,synchronized的内存语义。

    如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {
            synchronized (ThreadTest.class){
                //...
            }
            System.out.println(111);
        }
        System.out.println("t1线程结束");

    });

    t1.start();
    Thread.sleep(10);
    flag = false;
    System.out.println("主线程将flag改为false");
}
     3、加Lock

    Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到主内存的操作。

    Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。

    如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。

private static boolean flag = true;
private static Lock lock = new ReentrantLock();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {
            lock.lock();
            try{
                //...
            }finally {
                lock.unlock();
            }
        }
        System.out.println("t1线程结束");
    });
    t1.start();
    Thread.sleep(10);
    flag = false;
    System.out.println("主线程将flag改为false");
}
    4、final

    final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。

    final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的。

    final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。

2、原子性

2.1、原子性指的是什么意思?

        原子性的定义:原子性指一个操作是不可分割的,不可中断的,一个线程在执行时,另一个线程不会影响到他。

        原子性:多线程操作共享数据时,预期的结果,与最终的结果一致。

        不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要整体完善要么同时成功,要么同时失败

public class ThreadTest {

    private static volatile int count;

    public static void increment() {
       count++;
    }

}

//==========================================
PS D:\WorkSpace\xxxx\target\classes\com\lwz> javap -v .\ThreadTest.class
Classfile /D:/WorkSpace/xxxx/target/classes/com/lwz/ThreadTest.class
  Last modified 2023年12月12日; size 364 bytes
  SHA-256 checksum 53de37bd63a6c0161c3a4f44f519135e8ba324bf96ab3a3067337532ed627ea0
  Compiled from "ThreadTest.java"
public class com.lwz.ThreadTest
  minor version: 0
  major version: 52
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #3                          // com/lwz/ThreadTest
  super_class: #4                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #4.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#18         // com/lwz/ThreadTest.count:I
   #3 = Class              #19            // com/lwz/ThreadTest
   #4 = Class              #20            // java/lang/Object
   #5 = Utf8               count
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/lwz/ThreadTest;
  #14 = Utf8               increment
  #15 = Utf8               SourceFile
  #16 = Utf8               ThreadTest.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #5:#6          // count:I
  #19 = Utf8               com/lwz/ThreadTest
  #20 = Utf8               java/lang/Object
    descriptor: ()V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2             // Field count:I  //从主内存获取数据到寄存器
         3: iconst_1
         4: iadd                                           //在cpu内部执行+1操作
         5: putstatic     #2             // Field count:I  //将cpu寄存器数据写会到主内存
         8: return
      LineNumberTable:
        line 8: 0
        line 9: 8
}
SourceFile: "ThreadTest.java"

        上面程序,可以查看出,++的操作,一共分为了三步,首先是线程从主内存拿到数据保存到CPU的寄存器中,然后在寄存器中进行+1操作,最终将结果写回到主内存当中。

public class ThreadTest {

    private static volatile int count;

    public void increment() {
        synchronized (ThreadTest.class){
            count++;
        }
    }

}

//====================================================
...>javap -v .\ThreadTest.class
....
  public void increment();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class com/lwz/ThreadTest
         2: dup
         3: astore_1
         4: monitorenter                      //需要先获取到锁资源,才可以执行后面的代码
         5: getstatic     #3                  // Field count:I
         8: iconst_1
         9: iadd
        10: putstatic     #3                  // Field count:I
        13: aload_1
        14: monitorexit                       //指令执行完毕之后,会释放锁资源
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

        可以在方法上追加synchronized关键字或者采用同步代码块的形式来保证原子性。
        synchronized可以避免多线程同时操作临街资源,同一时间点,只会有一个线程正在操作临界资源。

2.2、volatile不保证原子性

验证不保证原子性

public class VolatileDemo {

	public static void main(String[] args) {
		MyData myData = new MyData();
		for (int i = 1; i <= 20; i++) {
			new Thread(() -> {
				for (int j = 1; j <= 1000; j++) {
					myData.addPlus();
				}
			}, String.valueOf(i)).start();
		}

		while (Thread.activeCount() > 2) {
			Thread.yield();
		}
		// 上面20个线程计算完最终结果
		System.out.println(Thread.currentThread().getName() + "\t num=" + myData.num);
	}

}

class MyData {
	volatile int num = 0;

	public void add() {
		this.num = 60;
	}

	public void addPlus() {
		this.num++;
	}
}

结果:main     num=16919       每一次运行结果都不一致。

2.3、如何保证原子性?

        1、加synchronized
        2、使用JUC下的AtomicInteger解决

        CAS:compare and swap也就是比较和交换,它是一条CPU的并发原语。
它在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。
    Java中基于Unsafe的类提供了对CAS的操作的方法,JVM会帮助我们将方法实现CAS汇编指令。但是要清楚CAS只是比较和交换,在获取原值的这个操作上,需要你自己实现。

    Doug Lea在CAS的基础上帮助我们实现了一些原子类,其中就包括现在看到的AtomicInteger,还有其他很多原子类。

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileDemo {

	public static void main(String[] args) {
		MyData myData = new MyData();
		for (int i = 1; i <= 20; i++) {
			new Thread(() -> {
				for (int j = 1; j <= 1000; j++) {
					myData.addPlus();
					myData.addAtomic();
				}
			}, String.valueOf(i)).start();
		}

		while (Thread.activeCount() > 2) {
			Thread.yield();
		}
		// 上面20个线程计算完最终结果
		System.out.println(Thread.currentThread().getName() + "\t num=" + myData.num);
		System.out.println(Thread.currentThread().getName() + "\t atomicInteger=" + myData.atomicInteger);
	}

}

class MyData {
	volatile int num = 0;

	public void add() {
		this.num = 60;
	}

	public synchronized void addPlus() {
		this.num++;
	}

	AtomicInteger atomicInteger = new AtomicInteger();

	public void addAtomic() {
		atomicInteger.getAndIncrement();
	}

}

结果:

main     num=20000
main     atomicInteger=20000
        3、Lock锁

    Lock锁是在JDK1.5由Doug Lea研发的,他的性能相比synchronized在JDK1.5的时期,性能好了很多多,但是在JDK1.6对synchronized优化之后,性能相差不大,但是如果涉及并发比较多时,推荐ReentrantLock锁,性能会更好。
    ReentrantLock可以直接对比synchronized,在功能上来说,都是锁。但是ReentrantLock的功能性相比synchronized更丰富。
    ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。

import java.util.concurrent.locks.ReentrantLock;

public class ThreadTest {

    private static int count;

    private static ReentrantLock lock = new ReentrantLock();

    public static void increment() {
        lock.lock();
        try {
            count++;
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}
        4、ThreadLocal

        ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据。

public class ThreadTest {

    static ThreadLocal tl1 = new ThreadLocal();
    static ThreadLocal tl2 = new ThreadLocal();

    public static void main(String[] args) {
        tl1.set("123");
        tl2.set("456");
        Thread t1 = new Thread(() -> {
            System.out.println("t1:" + tl1.get());
            System.out.println("t1:" + tl2.get());
        });
        t1.start();

        System.out.println("main:" + tl1.get());
        System.out.println("main:" + tl2.get());
    }

}

ThreadLocal实现原理:

  1. 每个Thread中都存储着一个成员变量,ThreadLocalMap
  2. ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap
  3. ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式实现。
  4. 每一个现有都自己独立的ThreadLocalMap,再基于ThreadLocal对象本身作为key,对value进行存取
  5. ThreadLocalMap的key是一个弱引用,弱引用的特点是,即便有弱引用,在GC时,也必须被回收。这里是为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收

public class Thread implements Runnable {
...
ThreadLocal.ThreadLocalMap threadLocals = null;
...
public class ThreadLocal<T> {
..

    static class ThreadLocalMap {

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
..
    }

..
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
..

ThreadLocal内存泄漏问题:

  1. 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果同时线程(线程池)还没有被回收,就会导致内存泄漏,内存中的value无法被回收,同时也无法被获取到。
  2. 只需要在使用完毕ThreadLocal对象之后,及时的调用remove方法,移除Entry即可。解决value内存泄漏问题。

3、有序性

3.1、什么是有序性

    在Java中,.java文件中的内容会被编译,在执行前需要再次转为CPU可以识别的指令,CPU在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排

    指令乱序执行的原因,是为了尽可能的发挥CPU的性能。

    Java中的程序是乱序执行的。

    计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下3种。

源代码-->编译器优化的重排-->指令并行的重排-->内存系统的重排-->最终执行的指令

    单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

    处理器在进行重排序时必须要考虑指令之间的数据依赖性

    多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

public class ReSortSeqDemo {
	int a = 0;
	boolean flg = false;

	public void m1() {
		a = 1;
		flg = true;
	}

	// 多线程环境中线程交替执行,由于编译器优化重排的存在,
	// 两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
// m1方法中的a=1和flg=true;的执行顺序因为有编译器重排的存在,有可能flg=true先执行,后执行a=1;
	// flg=true先执行时,突然m2方法线程执行,此时a=0+5;否则有可能a=1+5;

	public void m2() {
		if (flg) {
			a = a + 5;
		}
	}

}

Java程序验证乱序执行效果:

static int a,b,x,y;

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        a = 0;
        b = 0;
        x = 0;
        y = 0;

        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread t2 = new Thread(() -> {
            b = 1;
            y = a;
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        if(x == 0 && y == 0){
            System.out.println("第" + i + "次,x = "+ x + ",y = " + y);
        }
    }
}

    单例模式由于指令重排序可能会出现问题

    线程可能会拿到没有初始化的对象,导致在使用时,可能由于内部属性为默认值,导致出现一些不必要的问题

public class ThreadTest {
    private static volatile ThreadTest instance;

    private ThreadTest(){}

    public static ThreadTest getInstance(){
        // B
        if(instance  == null){
            synchronized (ThreadTest.class){
                if(instance == null){
                    // A   ,  开辟空间,test指向地址,初始化
                    instance = new ThreadTest();
                }
            }
        }
        return instance;
    }
}
3.2、as-if-serial

as-if-serial语义:

    1、不论指定如何重排序,需要保证单线程的程序执行结果是不变的

    2、而且如果存在依赖的关系,那么也不可以做指令重排。

// 这种情况肯定不能做指令重排序
int i = 0;
i++;

// 这种情况肯定不能做指令重排序
int j = 200;
j * 100;
j + 100;
// 这里即便出现了指令重排,也不可以影响最终的结果,20100
3.3、happens-before

happens-before具体规则:
  1. 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
  2. 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
  3. volatile的happen-before原则: 对一个volatile变量的写操作happen-before对此变量的任意操作。
  4. happen-before的传递性原则: 如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
  5. 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
  6. 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
  7. 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
  8. 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。
JMM只有在不出现上述8中情况时,才不会触发指令重排效果。

不需要过分的关注happens-before原则,只需要可以写出线程安全的代码就可以了

3.4、volatile

    如果需要让程序对某一个属性的操作不出现指令重排,除了满足happens-before原则之外,还可以基于volatile修饰属性,从而对这个属性的操作,就不会出现指令重排的问题了。

volatile如何实现的禁止指令重排?

    内存屏障概念。将内存屏障看成一条指令

    会在两个操作之间,添加上一道指令,这个指令就可以避免上下执行的其他指令进行重排序。

4、总结

        volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象

先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

    一是保证特定操作的执行顺序。

    二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

        由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

        对volatile变量进行写操作时,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回主内存。

        对volatile变量进行读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

volatile可应用在单例模式下

单例模式

AtomicInteger等底层原理

Java CAS学习

Java JUC高并发编程(一)

干我们这行,啥时候懈怠,就意味着长进的停止,长进的停止就意味着被淘汰,只能往前冲,直到凤凰涅槃的一天!

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杀神lwz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值