Java线程及多线程技术及应用(二)

5线程同步互斥

1、线程同步互斥的一个示例

多个线程同时访问或操作同一资源时,很容易出现数据前后不一致的问题。请看下面的例子:

 

男孩拿着折子去北京银行海淀分行取钱

                                       女孩拿着男孩的银行卡去西单百货疯狂购物

男孩走到柜台钱询问帐户余额

银行的业务员小姐亲切地告诉他:"您还有10000元!"

                                       女孩看上了一件时髦的衣裳,准备买下

男孩在思考要取多少钱呢?

                                       女孩到收银台准备刷卡消费

                                       收银台刷卡机读取银行卡余额为10000

                                       女孩买衣服刷卡消费5000

                                       消费清单打印出来,消费:5000  余额:5000

                                       女孩离开商场

男孩思考了1毫秒

男孩决定取5000

银行的业务员小姐为男孩办理相关业务手续

交易完成

银行的业务员小姐告诉男孩:"您的余额为5000"

男孩离开银行

 

男孩帐户中一共有10000元,男孩拿着存折从银行取走5000元,女孩拿着男孩的银行卡购物刷卡消费5000元,最后男孩的帐户里却还剩5000元。显然这是不正确的,但是为什么会发生这样的情况呢?我们可以这样分析:男孩可以看作是一条线程,女孩也可以看作是一条线程,在同一时刻,两个线程都操作了同一个资源,那就是男孩的帐户。男孩从查看帐户余额到取走现金应该被看作是个原子性操作,是不可再分的,然而当男孩查看完余额正思考取多少钱的时候,女孩购物消费了5000元,也就是说女孩这条线程打断了男孩这条线程所要执行的任务。所以男孩刚查看完的余额10000元就不正确了,最终导致帐户中少减了5000元。

为了避免这样的事情发生,我们要保证线程同步互斥,所谓同步互斥就是:并发执行的多个线程在某一时间内只允许一个线程在执行以访问共享数据

2、Java中线程互斥的实现机制

由多线程带来的性能改善是以可靠性为代价的,所以编程出线程安全的类代码是十分必要的。当多个线程可以访问共享资源(调用单个对象的属性和方法,对数据进行读、写、修改、删除等操作)时,应保证同时只有一个线程访问共享数据,Java对此提出了有效的解决方案—同步锁。任何线程要进入同步互斥方法(访问共享资源的方法或代码段)时,就必须得到这个共享资源对象的锁,线程进入同步互斥方法后其它线程则不能再进入同步互斥方法,直到拥有共享资源对象锁的线程执行完同步互斥方法释放了锁,下一个线程才能进入同步互斥方法被执行。

Java的这一线程互斥的实现机制可以用一个最通俗的比方来说明:比如公共卫生间就是一个共享资源,每个人都可以使用,但又不能同时使用,所以卫生间里有一把锁。一个人进去了,会把门锁上,其他人就不能进去。当Ta出来的时候,要打开锁,下一个人才能继续使用。

3、利用Synchronized关键字用于修饰同步互斥方法

(1)同步互斥方法

     public synchronized void method(){

         //允许访问控制的代码

    }

(2)同步互斥代码块

     synchronized(syncObject){

        //允许访问控制的代码

}

(3)锁定整个类

publicsynchronized class SyncObject{

    }

    由于synchronized 块可以针对任意的代码块,且可任意指定上锁的对象,因此灵活性较高。但要注意:

l  synchronized可以用来限定一个方法或一小段语句或整个类(该类中的所有方法都是synchronized方法)

l  将访问共享数据的代码设计为synchronized方法

l  由于可以通过 private 关键字来保证数据对象只能被方法访问,所以只需针对方法提出一套同步锁定机制。通过synchronized 方法来控制对类中的成员变量(共享数据)的访问。

l  编写线程安全的代码会使系统的总体效率会降低,要适量使用

l  只有某一个线程的synchronized方法执行完后其它线程的synchronized方法才能被执行。

l  当前时间,只有一个线程访问被锁定的代码段,但不能保证其他线程去访问其他没有被锁定的代码段。因此所有对共享资源进行操作的代码段都应该加锁。

l  对数据库操作时,修改数据的线程要加锁,而读数据的线程可以不加锁

有了这种解决方案,我们用线程安全的代码来重新实现一下男孩和女孩取钱的故事。以下是核心代码:

package com.px1987.j2se.thread.synchronous.v2;
/** 帐户类 */
public class Account {
	/** 余额	 */
	private int balance;
	public Account(int balance) {
		this.balance = balance;
	}
}
package com.px1987.j2se.thread.synchronous.v2;
/** 男孩类,实现Runnable接口*/
public class Boy implements Runnable {
	/** 银行帐户*/
	Account account;	
	public Boy(Account account) {
	
		this.account = account;
	}	
	/** 男孩拿着折子去北京银行海淀分行取钱*/
	public void run() {
		System.out.println("男孩拿着折子去北京银行海淀分行取钱");
		synchronized (account) {
			System.out.println("男孩走到柜台钱询问帐户余额");
			int balance = account.getBalance();
			System.out.println("银行的业务员小姐亲切地告诉他:\"您还有" + 
balance + "元!\"。");
			try {
				System.out.println("男孩在思考要取多少钱呢?");
				Thread.sleep(1);
				System.out.println("男孩思考了1毫秒");
			} 
catch (InterruptedException e) {
				e.printStackTrace();
			}
			int money = 5000;
			System.out.println("男孩决定取" + money + "元");
			System.out.println("银行的业务员小姐为男孩办理相关业务手续");
			account.setBalance(balance - money);
			System.out.println("交易完成");
			System.out.println("银行的业务员小姐告诉男孩:\"您的余额为" + 
account.getBalance()+ "元\"。");
		}
		System.out.println("男孩离开银行");
	}
}
package com.px1987.j2se.thread.synchronous.v2;
/** 女孩类,实现runnable接口*/
public class Girl implements Runnable {
	/** 女孩持有男孩的银行卡*/
	Account account;
	public Girl(Account account) {
		
		this.account = account;
	}
	/*** "女孩拿着小军的银行卡去西单百货疯狂购物*/
	public void run() {
		String tabs = "\t\t\t\t\t\t";
		System.out.println(tabs + "女孩拿着小军的银行卡去西单百货疯狂购物");
		System.out.println(tabs + "女孩看上了一件时髦的衣裳,准备买下");
		synchronized (account) {
			System.out.println(tabs + "女孩到收银台准备刷卡消费");
			int balance = account.getBalance();
			System.out.println(tabs + "收银台刷卡机读取银行卡余额为" + balance + "元");
			int payout = 5000;
			System.out.println(tabs + "女孩买衣服刷卡消费" + payout + "元");
			account.setBalance(balance - payout);
			System.out.println(tabs + "消费清单打印出来,消费:" + payout + "元" + "  余额:"
+ account.getBalance() + "元");
		}
		System.out.println(tabs + "女孩离开商场");
	}
}
package com.px1987.j2se.thread.synchronous.v2;
public class Bank {
	public static void main(String[] args) {
		Account account=new Account(10000);
		Thread boyThread=new Thread(new Boy(account));
		Thread girlThread=new Thread(new Girl(account));
		boyThread.start();
		girlThread.start();
	}
}

修改后的代码运行结果如下图:

男孩拿着折子去北京银行海淀分行取钱

                                       女孩拿着小军的银行卡去西单百货疯狂购物

                                       女孩看上了一件时髦的衣裳,准备买下

                                       女孩到收银台准备刷卡消费

                                       收银台刷卡机读取银行卡余额为10000

                                       女孩买衣服刷卡消费5000

                                       消费清单打印出来,消费:5000  余额:5000

                                       女孩离开商场

男孩走到柜台钱询问帐户余额

银行的业务员小姐亲切地告诉他:"您还有5000元!"

男孩在思考要取多少钱呢?

男孩思考了1毫秒

男孩决定取5000

银行的业务员小姐为男孩办理相关业务手续

交易完成

银行的业务员小姐告诉男孩:"您的余额为0"

男孩离开银行

 

从结果中可以看出来,男孩从查看余额到取钱,女孩没有操作帐户,所以最后的余额是正确的。

4、线程死锁

使用互斥锁容易产生死锁问题。比如:一个线程需要锁定两个对象才能完成,线程1拥有对象A的锁,线程1如果再拥有对象B的锁就能完成操作,线程2拥有对象B的锁,线程2如果再拥有对象A的锁就能完成操作。

很不幸的是线程1执行不下去了,因为线程1等待的资源对象B被线程2锁住了,线程2也执行不下去了,因为线程2等待的资源对象A被线程1锁住了,这样就造成了死锁。

 

阅读一段文字:由多线程带来的性能改善是以可靠性为代价的,主要是因为有可能产生线程死锁。死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不能正常运行。简单的说就是:线程死锁时,第一个线程等待第二个线程释放资源,而同时第二个线程又在等待第一个线程释放资源。这里举一个通俗的例子:如在人行道上两个人迎面相遇,为了给对方让道,两人同时向一侧迈出一步,双方无法通过,又同时向另一侧迈出一步,这样还是无法通过。假设这种情况一直持续下去,这样就会发生死锁现象。
    导致死锁的根源在于不适当地运用“synchronized”关键词来管理线程对特定对象的访问。“synchronized”关键词的作用是,确保在某个时刻只有一个线程被允许执行特定的代码块,因此,被允许执行的线程首先必须拥有对变量或对象的排他性访问权。当线程访问对象时,线程会给对象加锁,而这个锁导致其它也想访问同一对象的线程被阻塞,直至第一个线程释放它加在对象上的锁。

 

1)死锁问题的一个代码示例

package com.px1987.j2se.thread.DeadLock;
class Thread1 implements Runnable {
	private Object a;
	private Object b;
	public Thread1(Object a, Object b) {
		super();
		this.a = a;
		this.b = b;
	}
	public void run() {
		synchronized (a) {
			System.out.println("Thread1获得对象a的锁");
			try {
				Thread.sleep(1);
			} 
catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (b) {
				System.out.println("Thread1获得对象b的锁");
				try {
					Thread.sleep(1);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
}
package com.px1987.j2se.thread.DeadLock;
class Thread2 implements Runnable {
	private Object a;
	private Object b;
	public Thread2(Object a, Object b) {
		super();
		this.a = a;
		this.b = b;
	}
	public void run() {
		synchronized (b) {
			System.out.println("Thread2获得对象b的锁");
			try {
				Thread.sleep(1);
			}
catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (a) {
				System.out.println("Thread2获得对象a的锁");
				try {
					Thread.sleep(1);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
}
package com.px1987.j2se.thread.DeadLock;
public class TestDeadLock {
	public static void main(String[] args) {
		Object a=new Object();
		Object b=new Object();
		Thread thread1=new Thread(new Thread1(a,b));
		Thread thread2=new Thread(new Thread2(a,b));
		thread1.start();
		thread2.start();
		
	}
}

(2)死锁问题的另一个代码示例

package com.px1987.j2se.thread.DeadLock;
public class ThreadDeadLock {	
	public static void main(String[] args) {
		ThreadOne threadOne=new ThreadOne();
		ThreadTwo threadTwo=new ThreadTwo();
		String s1="s";
		String s2="sss";
		threadOne.op1=s1;
		threadTwo.op1=s1;
		threadOne.op2=s2;
		threadTwo.op2=s2;
		threadOne.start();
		threadTwo.start();
	}
}
class ThreadOne extends Thread{	
	String op1;
	String op2;	
	public void run(){// 同步中又有同步,就可能死锁
		synchronized(op1){			
			System.out.println(Thread.currentThread().getName()+"锁定op1");
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized(op2){				
				System.out.println(Thread.currentThread().getName()+"锁定op2");
			}			
		}		
	}
}
class ThreadTwo extends Thread{	
	String op1;
	String op2;	
public void run(){
		synchronized(op2){
			System.out.println(Thread.currentThread().getName()+"锁定op2");
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized(op1){				
				System.out.println(Thread.currentThread().getName()+"锁定op1");
			}			
		}		
	}
}

6生产者消费者问题

1、生产者消费者问题的示例

生产者消费者问题也是一个典型的线程问题。我们举一个这方面的实例来说明:在一个果园里,有农夫和小孩,农夫会不停的采摘水果放入果园中心的一个水果筐直到水果筐满,而小孩会不停的从水果筐里拿水果来吃,直到水果拿完。分析这个模型我们可以看出:农夫可以看成是一个生产者的线程,小孩可以看成是一个消费者的线程,而大水果筐是共享资源。

2、用Java程序表述的代码示例

package com.px1987.j2se.thread.ProducerConsumer;
import java.util.Random;
/*** 水果类*/
public class Fruit {
	/*** 水果编号*/
	private int id;
	/*** 水果编号计数器*/
	private static int number = 0;
	/*** 水果品种 */
	private String variety;
	/*** 水果品种数组 */
	private String[] varietys = "苹果,桃子,梨子,香蕉,西瓜,荔枝,葡萄".split(",");
	public Fruit() {
		super();
		this.variety = varietys[new Random().nextInt(7)];
		this.id = ++number;
	}
}

水果筐应该设计成类似于栈的数据结构,其中包含一个数组来存放筐里的水果,而数组的下标就是水果筐的容量。设定一个索引index表示指向下一个将要放入水果的位置。类中的push方法模拟农夫向水果筐中放入水果,pop方法模拟小孩从水果筐中拿水果。这两个方法都要操作共享资源,所以pushpop方法都是同步互斥方法。

3、如何避免出现死锁

那同步的问题解决后是否会出现死锁呢?大家试想一下,如果生产的速度大于消费的速度就会导致功大于求,水果筐很容易就满了,然而生产者又一直抱着水果筐不放,没有机会给消费者使用,消费者不消费生产者就无法生产,所以就造成了死锁。

怎样解决呢?在两个同步互斥方法中用到了waitnotify方法,这两个方法是为了防止死锁的。

waitObject类的方法,它的作用是拥有互斥锁的线程放弃锁的使用权,进入wait池进行等待,那么互斥锁就有可能被其他线程获得以执行其他任务。

notify也是Object类的方法,它的作用是从wait池中唤醒一条正在等待的线程进入就绪状态,被唤醒的这条线程就很可能重新获得cup和互斥锁来完成它的任务。

notifyAllNotify很相似,它是从wait池中唤醒所有正在等待的线程进入就绪状态。

需要注意的是以上三个方法都只能在synchronized方法中应用,否者会出现下面的异常信息:IllegalMonitorStateException:current thread not owner。

4、实现的代码示例

package com.px1987.j2se.thread.ProducerConsumer;
import java.text.DecimalFormat;
import java.util.Arrays;
/*** 水果框类,类似一个栈的模型 */
public class FruitBasket {
	/*** 容量为10的水果数组,也就是说水果框最多能放下10个水果	 */
	private Fruit[] fruits = new Fruit[10];
	/*** 下一个将要放入水果的位置*/
	private int index = 0;
	/*** 水果框中是否为空 @return true为空,false为不空 */
	public boolean isEmpty() {
		return index == 0 ? true : false;
	}
	/*** 水果框是否装满* @return true为满,false为未满*/
	public boolean isFull() {
		return index == fruits.length ? true : false;
	}
	/*** 进栈方法,模拟农夫把水果放入筐中,@param name 农夫的名字,@param fruit 水果对象 */
	public synchronized void push(String name, Fruit fruit) {
		//用while循环,不用if,避免IndexOutOfBoundsException异常的产生
		while (isFull()) {
			//如果水果筐满了,需要等待
			try {
				this.wait();
			}
catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		//将水果放入index指示的位置,index再上移一格
		fruits[index++] = fruit;
		System.out.println(name + " 向水果框中放入编号为" + fruit.getId() + "的"+ 
							fruit.getVariety());
		display();		
		this.notify();	//通知其他等待的农夫或孩子可以开始工作啦
	}
	/*** 出栈方法,模拟小孩从水果筐中取出水果,@param name 小孩的名字,@return 取出的水果*/
	public synchronized Fruit pop(String name) {
		//用while循环,不用if,避免IndexOutOfBoundsException异常的产生
		while (isEmpty()) {			
			try {	//如果水果筐空,需要等待
				this.wait();
			} 
catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		Fruit fruit = null;		
		fruit = fruits[--index];	//index下移一位,取出指示位置上的水果
		System.out.println(name + " 从水果框中拿出编号为" + fruit.getId() + "的"+
fruit.getVariety());
		display();
		this.notify();
		return fruit;
	}
	/*** 显示水果筐中水果存放情况*/
	public void display() {
		for (int i = 0; i < index; i++)
			System.out.printf("%-10s", " NO:" + 
new DecimalFormat("00").format(fruits[i].getId())
								+ fruits[i].getVariety() + " |");
			for (int i = index; i < fruits.length; i++) {
				System.out.printf("%-10s", "   【" + (i + 1) + "】   |");
			}
			System.out.println();
		}
}
package com.px1987.j2se.thread.ProducerConsumer;
import java.util.Random;
/** 果园里的农夫类,他是生产者,实现Runnable接口*/
public class Farmer implements Runnable {
	/** 姓名*/
	private String name;
	/** 水果框*/
	private FruitBasket fruitBasket;
	/** 农夫会不停地重复这一系列动作:从水果树上采摘一个水果放入水果框中,然后随机的休息0-2秒*/
	public void run() {
		while (true) {
			fruitBasket.push(name, new Fruit());
			try {
				Thread.sleep(new Random().nextInt(2000));
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public Farmer(String name, FruitBasket fruitBasket) {
		super();
		this.name = name;
		this.fruitBasket = fruitBasket;
	}
}
package com.px1987.j2se.thread.ProducerConsumer;
import java.util.Random;
/*** 果园中的小孩类,他是消费者,实现Runnable接口*/
public class Child implements Runnable {
	/*** 姓名*/
	private String name;
	/*** 水果框*/
	private FruitBasket fruitBasket;
	/*** 小孩会不停地重复这一系列动作:从水果框中拿出水果吃,然后随机休息0-5秒钟*/
	public void run() {
		while (true) {
			fruitBasket.pop(name);
			try {
				Thread.sleep(new Random().nextInt(5000));
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public Child(String name, FruitBasket fruitBasket) {
		super();
		this.name = name;
		this.fruitBasket = fruitBasket;
	}
}

package com.px1987.j2se.thread.ProducerConsumer;
import java.util.Random;
/*** 果园中的小孩类,他是消费者,实现Runnable接口*/
public class Child implements Runnable {
	/*** 姓名*/
	private String name;
	/*** 水果框*/
	private FruitBasket fruitBasket;
	/*** 小孩会不停地重复这一系列动作:从水果框中拿出水果吃,然后随机休息0-5秒钟*/
	public void run() {
		while (true) {
			fruitBasket.pop(name);
			try {
				Thread.sleep(new Random().nextInt(5));
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public Child(String name, FruitBasket fruitBasket) {
		super();
		this.name = name;
		this.fruitBasket = fruitBasket;
	}
}

测试时使用多个生产者线程和多个消费者线程

package com.px1987.j2se.thread.ProducerConsumer;
/** 果园类,测试*/
public class Orchard {
	public static void main(String[] args) {
		FruitBasket fruitBasket = new FruitBasket();
		Thread farmerThread1 = new Thread(new Farmer("农夫1", fruitBasket));
		Thread farmerThread2 = new Thread(new Farmer("农夫2", fruitBasket));
		Thread farmerThread3 = new Thread(new Farmer("农夫3", fruitBasket));
		Thread childThread1 = new Thread(new Child("小孩1", fruitBasket));
		Thread childThread2 = new Thread(new Child("小孩2", fruitBasket));
		Thread childThread3 = new Thread(new Child("小孩3", fruitBasket));
		farmerThread1.start();
		farmerThread2.start();
		farmerThread3.start();
		childThread1.start();
		childThread2.start();
		childThread3.start();
	}
}

程序的运行结果如下:

 

农夫1 向水果框中放入编号为1的苹果

 NO:01苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫3 向水果框中放入编号为2的荔枝

 NO:01苹果 |NO:02荔枝 |  【3】   |   【4】   |   【5】   |   【6】   |

小孩2 从水果框中拿出编号为2的荔枝

 NO:01苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫2 向水果框中放入编号为3的香蕉

 NO:01苹果 |NO:03香蕉 |  【3】   |   【4】   |   【5】   |   【6】   |

小孩1 从水果框中拿出编号为3的香蕉

 NO:01苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

小孩3 从水果框中拿出编号为1的苹果

   【1】   |   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫2 向水果框中放入编号为4的苹果

 NO:04苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

小孩1 从水果框中拿出编号为4的苹果

   【1】   |   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫1 向水果框中放入编号为5的苹果

 NO:05苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫3 向水果框中放入编号为6的西瓜

 NO:05苹果 |NO:06西瓜 |  【3】   |   【4】   |   【5】   |   【6】   |

农夫2 向水果框中放入编号为7的苹果

 NO:05苹果 |NO:06西瓜 | NO:07苹果|   【4】   |   【5】   |   【6】   |

小孩3 从水果框中拿出编号为7的苹果

 NO:05苹果 |NO:06西瓜 |  【3】   |   【4】   |   【5】   |   【6】   |

小孩3 从水果框中拿出编号为6的西瓜

 NO:05苹果|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

小孩2 从水果框中拿出编号为5的苹果

   【1】   |   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫2 向水果框中放入编号为8的桃子

 NO:08桃子|   【2】   |   【3】   |   【4】   |   【5】   |   【6】   |

农夫1 向水果框中放入编号为9的荔枝

 NO:08桃子 |NO:09荔枝 |  【3】   |   【4】   |   【5】   |   【6】   |

农夫3 向水果框中放入编号为10的香蕉

 NO:08桃子 |NO:09荔枝 | NO:10香蕉|   【4】   |   【5】   |   【6】   |

农夫1 向水果框中放入编号为11的桃子

 NO:08桃子 |NO:09荔枝 | NO:10香蕉 |NO:11桃子 |  【5】   |   【6】   |

农夫1 向水果框中放入编号为12的荔枝

 NO:08桃子 |NO:09荔枝 | NO:10香蕉 |NO:11桃子 | NO:12荔枝|   【6】   |

农夫3 向水果框中放入编号为13的西瓜

 NO:08桃子 | NO:09荔枝 | NO:10香蕉 | NO:11桃子 | NO:12荔枝 | NO:13西瓜 |

小孩1 从水果框中拿出编号为13的西瓜

 NO:08桃子 |NO:09荔枝 | NO:10香蕉 |NO:11桃子 | NO:12荔枝|   【6】   |

农夫2 向水果框中放入编号为14的西瓜

 NO:08桃子 | NO:09荔枝 | NO:10香蕉 | NO:11桃子 | NO:12荔枝 | NO:14西瓜 |

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

未名胡

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

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

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

打赏作者

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

抵扣说明:

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

余额充值