黑马程序员——多线程6:线程间通信

------- android培训java培训、期待与您交流! ----------

1. 同时对共享资源进行加减法

       在前面的多线程相关内容中,无论是售票的例子还是银行存款的例子,所有线程的动作都是一致的:售票例子中所有线程均对共享数据资源做减法,而银行存款例子中是加法。然而在实际生活中,对于一些“共享资源”,“加法”和“减法”通常是同时发生的。举个例子,比如三峡大坝,上游的水不断流入水库中,这相当于“加法”,同时还要开闸放水来发电,这相当于“减法”。

       那么既然有两个不同的操作资源动作,就要定义两种run方法,必然也就要定义两个实现Runnable接口的类。我们可以通过下图来更为直观的理解这一过程。 

下面我们以一个实际的代码实例来说明。

需求:模拟对个人信息更新与获取的交替执行。

分析:可以将这个共享资源想像为一个缓存,首先向该缓存添加一个个人信息对——姓名+性别,添加完毕以后就立即获取该信息对,然后再添加一个新的信息对,并将前一个信息对覆盖,接着获取,以此类推,循环往复。

实现方式:首先定义一个共享资源类——PersonInfo,该类内部定义两个成员变量——name和gender,并分别为这两个成员变量定义set和get方法,便于添加和获取信息。再定义两个实现Runnable接口的资源操作类——Input和Output,分别表示信息添加类和信息获取类。为了保证这两个类操作同一个信息资源,首先创建一个PersonInfo对象,并传入Input和Output对象的构造方法,在他们内部初始化唯一的一个PersonInfo对象。在PersonInfo类中再分别定义添加男性信息和添加女性的方法。

        两个对象的操作过程如下:为方便观察信息添加和获取过程,在Input类内部定义一个标记,标记值为0和1。当标记为0时添加并获取男性信息,当标记为1时添加并获取并获取女性信息,以此类推,循环往复。

代码1:

//定义资源类,该例中是个人信息

class PersonInfo
{
	//个人信息中包含姓名和性别,并分别对外提供获取和设置方法
	private String name, gender;
	public void setName(String name)
	{
		this.name = name;
	}
	public String getName()
	{
		return name;
	}
	public void setGender(String gender)
	{
		this.gender = gender;
	}
	public String getGender()
	{
		return gender;
	}
}
//定义资源添加类
class Input implements Runnable
{
	/*
		为了保证两个线程操作共享资源的唯一性
		在创建Input和Output对象前先创建一个PersonInfo对象
		并将该对象分别传入Input和Output对象的构造方法
		为每个资源操作对象初始化一个资源对象,并且该对象是唯一对象
	*/
	private PersonInfo pi;
	Input(PersonInfo pi)
	{
		this.pi  = pi;
	}
	public void run()
	{
		//定义标记,通过判断标记,决定添加哪个信息
		int x = 0;
		//不断循环添加个人信息
		while(true)
		{
			/*
				为了便于观察添加信息获取信息的过程
				每次在男性和女性个人信息之间不断交替
				如果标记为0添加男性,否则添加女性
			*/
			if(x == 0)
				setMale();
			else
				setFemale();
			//通过下面的计算,不断改变标记值为0或1
			x = (x + 1) % 2;
		}
	}
	//将个人信息设为男性
	private void setMale()
	{
		pi.setName("mike");
		pi.setGender("male");
	}
	//将个人信息设为女性
	private void setFemale()
	{
		pi.setName("小红");
		pi.setGender("女");
	}
}
//定义资源获取类
classOutput implements Runnable
{
	private PersonInfo pi;
	Output(PersonInfo pi)
	{
		this.pi = pi;
	}
	public void run()
	{
		//不断循环获取个人信息
		while(true)
		{
			System.out.println("name:"+pi.getName()+"-----"+"gender: "+pi.getGender());
		}
	}
}
class ThreadDemo
{
	public static void main(String[] args)
	{
		//创建资源对象
		PersonInfo pi = new PersonInfo();
 
		//分别创建并开启添加和获取线程
		Thread in = new Thread(newInput(pi));
		Thread out = new Thread(newOutput(pi));
		in.start();
		out.start();
	}
}
运行结果中可能会出现这样的错误现象:

name: 小红-----gender:male

name:mike-----gender: 女

        这是由于当添加信息线程向资源中添加个人信息时,只添加了姓名,而还没来得及添加性别时,其执行权就被获取信息线程夺去了,此时获取信息线程就将刚添加进来的名称和还没来得及覆盖的性别信息打印了出来,就造成了上述错误现象。那么这里就又出现了线程安全问题。

 

分析二:既然出现了线程安全问题,就要用到同步技术来解决。还是同样的思路:只需将所有操作共享资源的代码定义进同步代码块或者同步函数即可,并且还要保证所有同步代码使用的锁是相同的。

实现方法二:代码1中,Input类setMale和setFemale方法中的所有代码,以及Output类run方法中循环内的代码均是对共享资源的操作,将上述三部分代码分别定义到三个同步代码块中,而锁对象全部设置为共享资源对象PersonInfo pi。那么我们可不可以将setMale、setFemale和Output类的run方法均设为同步函数呢?由于同步函数的锁是this,就不能保证上述三个方法的锁对象是同一个对象,起不到同步的作用。

代码二:按照上述实现方法二修改setMale、setFemale和Output类中run方法循环内的代码。

代码2:

class Input implements Runnable
{
	//省略部分代码以便阅读
	private void setMale()
	{
		/*
			加上同步代码块以保证线程安全
			用唯一的对象作为锁——共享资源对象
			setFemale方法以及Output类run方法循环内代码同理
		*/
		synchronized(pi)
		{
			pi.setName("mike");
			pi.setGender("male");
		}
	}
	private void setFemale()
	{
		synchronized(pi)
		{
			pi.setName("小红");
			pi.setGender("女");
		}
	}
class Output implements Runnable
{
	//省略部分代码以便阅读
	public void run()
	{
		while(true)
		{
			synchronized(pi)
			{
				System.out.println("name:"+pi.getName()+"-----"+"gender:"+pi.getGender());
			}
		}
	}
}
这样就避免了错误信息的打印。

2. 等待唤醒机制

       执行修改后的代码虽然解决了线程安全问题,但是没能实现需求中对个人信息“交替”地更新和获取,实际的运行结果是要么连续打印男性信息,要么连续打印女性信息。之所以产生这种现象是因为,线程执行资格的分配是由CPU随机决定的,比如信息添加线程向共享资源中添加信息的动作就会由于一直拥有执行资格而不断执行,信息获取线程就无法获取并打印个人信息,或者信息获取线程由于一直拥有执行资格而不断打印信息。那么为了解决这个问题,就要使用线程间的等待唤醒机制了。

分析三:

       出现上述现象的关键原因是,线程未能及时释放其执行权。对于信息添加线程来说,当他添加完一个信息对以后就应该释放执行权给信息获取线程,同样信息获取线程获取并打印信息以后也应该释放执行权给信息添加线程,这样才能实现信息添加和获取的交替执行。

       在前面的内容中我们曾说道,可以通过人为调用sleep或wait方法使线程处于冻结状态而同时释放执行资格和执行权。但我们无法事先知道一个线程冻结的时长(对于sleep方法),因此应通过wait和notify方法的配合来完成。notify是由活动线程调用来唤醒冻结线程的方法。

实现方式三:

       PersonInfo类:首先需要在PersonInfo类中定义私有布尔型成员变量flag,表示标记,初始化值为false,表示共享资源中并没有存入信息,并为该变量定义set和get方法。

Input类:将Input类中setMale和setFemale方法定义为普通方法(不再是同步函数),并将while循环内的所有代码均定义到同步代码块内,锁依然置为pi对象。同步代码块内,首先判断共享资源的标记,如果标记为真,就通过调用锁对象pi的wait方法冻结该线程(无论是调用wait方法还是notify方法,都通过锁对象调用,下同)。否则,通过0和1标记添加男性或女性信息。添加完毕,将flag标记置为真,表示共享资源内已存有信息,然后调用notify方法唤醒信息获取线程。当再次判断flag标记时调用wait进入冻结状态。

       Output类:同样将循环内的代码全部定义到同步代码块中,锁对象也是pi。同步代码块内同样先判断flag标记,如果非假就调用wait方法冻结,否则,获取并打印信息。接着将flag标记置为假,表示信息获取完毕,最后唤醒信息添加线程。再次判断flag标记,标记值为非假,进入冻结状态。就这样,实现了两个线程交替添加和获取信息的动作。这里之所以强调标记是“非假”,是因为信息获取与信息添加线程的标记通常是相反的,如果信息添加线程标记为真,那么信息获取线程标记就得取反。

代码三:

代码3:

class PersonInfo
{
	private String name, gender;
	//定义标记,表示共享资源中是否存有信息
	private boolean flag = false;
	public void setName(String name)
	{
		this.name = name;
	}
	public String getName()
	{
		return name;
	}
	public void setGender(String gender)
	{
		this.gender = gender;
	}
	public String getGender()
	{
		return gender;
	}
	//为标记定义设置和获取方法
	public booleangetFlag()
	{
		return flag;
	}
	public void setFlag(boolean flag)
	{
		this.flag = flag;
	}
}
 class Input implements Runnable
{
	private PersonInfo pi;
	Input(PersonInfo pi)
	{
		this.pi = pi;
	}
	public void run()
	{
		int x = 0;
		while(true)
		{
			synchronized(pi)
			{
				/*
					判断标记,如果为真表示存有数据
					那么信息添加线程将进入冻结状态而停止添加信息
				*/
				if(pi.getFlag())
					//由于wait方法声明了异常,这里需要try-catch处理,处理代码从简
					try{pi.wait();}catch(Exceptione){}
 
				if(x == 0)
					setMale();
				else
					setFemale();
				x = (x + 1) % 2;
				//添加信息以后,将标记置为真表示已存入信息
				pi.setFlag(true);
				//存入信息后唤醒信息获取线程
				pi.notify();
			}
		}
	}
	private void setMale()
	{
		pi.setName("mike");
		pi.setGender("male");
	}
	private void setFemale()
	{
		pi.setName("小红");
		pi.setGender("女");
	}
}
class Output implements Runnable
{
	private PersonInfo pi;
	Output(PersonInfo pi)
	{
		this.pi = pi;
	}
	public void run()
	{
		while(true)
		{
			synchronized(pi)
			{
				/*
					判断标记,如果非真表示共享资源中没有存入信息
					那么就进入冻结状态,停止获取
				*/
				if(!pi.getFlag())
					try{pi.wait();}catch(Exceptione){}
				System.out.println("name:"+pi.getName()+"-----"+"gender:"+pi.getGender());
				//将信息获取并打印后,将标记置为假表示已获取信息,共享资源已空
				pi.setFlag(false);
				//唤醒信息添加线程
				pi.notify();
			}
		}
	}
}
class ThreadDemo2
{
	public static void main(String[] args)
	{
		PersonInfo pi = new PersonInfo();
 
		Thread in = new Thread(newInput(pi));
		Thread out = new Thread(newOutput(pi));
		in.start();
		out.start();
	}
}
        关于代码3我们还要进行一点说明。我们在前面的内容中说到,一旦某个“锁”被锁定,那么与之相关的所有同步代码都将无法访问,直到“锁”被释放。那么我们的问题是,当信息添加线程向共享资源中添加完一个信息对以后,循环判断标记线程被冻结,按理说此时“锁”并未被释放,为什么信息获取线程可以执行对应的同步代码呢?这是因为当某个线程通过wait方法被冻结时,将会同时释放“锁”,不需要将同步代码全部执行完毕,这是与sleep方法不同的地方。

小知识点1:

        线程一旦被冻结就会被存储到内存中的线程池。那么notify方法唤醒的通常是第一个被冻结的线程。如果想要唤醒所有冻结的线程,可以调用notifyAll方法。

小知识点2:

       可能有朋友会问,既然wait方法和notify方法都是在操作线程的冻结与唤醒,为什么不去调用线程对象的上述方法,却要调用锁对象的呢?如果查阅API文档我们就会发现,无论是wait方法还是notify方法,抑或是notifyAll方法都是定义在了Object类内,而没有定义在Thread类内部。这看起来是违反了我们的直觉的事情。而之所以这么定义是因为,无论是wait还是notify方法,都是在操作的持有“锁”对象的那个线程,换句话说,谁锁上了“锁”谁就持有“锁”,wait方法和notify方法就通过这个“锁”对象去操作持有该“锁”的那个线程。这么做是为了在同步代码嵌套情况下,实现线程间的等待唤醒机制。因为不同同步代码使用的锁不同(就像前面死锁示例中那样),就通过持有的锁来区分具体应该冻结或者唤醒哪个线程。否则就有可能将并不是操作同一个共享资源的其他线程冻结或者唤醒。由于锁的任意性,就必须将wait和notify方法定义在所有类的跟类——Object类中。

3. 生产者与消费者

       在前面的内容中我们举了一个交替添加获取个人信息的例子来说明了线程间通信的等待唤醒机制。但是在该例中分别只有一个线程负责添加和获取,而在实际开发中常常会涉及更多的线程。因此在这部分内容中我们再举一个常用的例子——生产者消费者来模拟在多于两个线程时如何实现线程间的等待唤醒机制。

需求:

       以抽象“商品”作为被生产和消费的对象。模拟当分别只有一个生产者和消费者时,交替生产和消费的过程。

分析一:

       这里我们先对代码3做一些修改。将操作共享资源的代码定义到共享资源类Resource中,以此来降低在生产者Producer类和消费者Consumer类内操作共享资源代码的复杂程度。初步地只分别创建一个生产者线程和消费者线程。其他部分的原理与上例基本相同。

实现方式一:

        Resource类:共享资源类。定义三个私有成员变量:String name,表示被生产和消费的对象名称——“商品”;int count,表示商品编号,初始化值为1,依次递增;boolean flag,表示标记,初始化值为false,以此决定共享资源中是否存有“商品”。

        定义两个公有成员方法:produce方法,表示生产“商品”。首先判断标记,若真(表示存有商品,下同),冻结(通过wait,下同)线程;否则将调用produce方法时传入的商品名和商品编号赋予name,表示生产了一个商品。为了便于观察,连同执行该操作的线程名、角色名(此为生产者)以及商品名打印于控制台。接着将标记置为真,唤醒消费者线程;consume方法,首先判断标记,若标记为假,则表示没有商品供消费,冻结;否则将线程名、角色名(此为消费者)以及商品名打印输出在控制台。将标记置为假,唤醒生产者线程。

        Producer类:实现Runnable接口,构造函数中初始化唯一的一个共享资源对象Resource res(下同)。run方法中循环语句内调用res对象的produce方法并传入商品名称——“商品”。

        Consumer类:实现Runnable接口,构造函数中同样初始化唯一的共享资源对象res。run方法中循环执行res对象的consume方法。

代码一:

代码4:

class Resource
{
	//商品名
	private String name;
	//商品编号
	private int count = 1;
	private boolean flag = false;
 
	/*
		为提高代码的阅读性
		不再把操作共享资源的代码定义在生产者与消费者内部
		而是定义在共享资源类produce(生产)方法和consume(消费)方法内
		由于produce与consume方法内的代码均是对共享资源的操作
		因此将两方法定义为同步函数
	*/
	public synchronized void produce(String name)
	{
		if(flag)
			try{wait();}catch(Exception e){}
		//将商品名和编号赋予name,表示生产了一个商品,然后编号自增
		this.name = name+"--"+count++;
 
		//为便于观察程序执行过程,打印线程名+角色名+被生产的商品名
		System.out.println(Thread.currentThread().getName()+"...生产者..."+name);
		flag =true;
		this.notify();
	}
	public synchronized void consume()
	{
		if(!flag)
			try{this.wait();}catch(Exceptione){}
 
		//打印线程名+角色名+被消费的商品名
		System.out.println(Thread.currentThread().getName()+"..........消费者.........."+name);
		flag =false;
		this.notify();
	}
}
class Producer implements Runnable
{
	//初始化唯一的共享资源对象
	private Resource res;
	Producer(Resource res)
	{
		this.res = res;
	}
 
	public void run()
	{
		while(true)
		{
			res.produce("+商品+");
		}
	}
}
class Consumer implements Runnable
{
	private Resource res;
	Consumer(Resource res)
	{
		this.res = res;
	}
 
	public void run()
	{
		while(true)
		{
			res.consume();
		}
	}
}
class ProducerConsumerDemo
{
	public static void main(String[]args)
	{
		Resource res = new Resource();
 
		Producer pro = new Producer(res);
		Consumer con = new Consumer(res);
 
		Thread t1 = new Thread(pro);
		Thread t2 = new Thread(con);
		t1.start();
		t2.start();
	}
}
从运行结果来看,当分别只有一个生产者线程和消费者线程时,程序的运行是正常的。但是当我们分别将生产者线程个数和消费者线程个数增加至两个时,就像下面的代码:

Thread t1 = new Thread(pro);//生产者线程

Thread t2 = new Thread(pro); //生产者线程

Thread t3 = new Thread(con);//消费者线程

Thread t4 = new Thread(con);//消费者线程

可能会产生如下错误现象:

Thread-..生产者...+商品+--2

Thread-..生产者...+商品+--3

Thread-.........消费者..........+商品+--3

也就是说,生产了两个商品但是只消费了一个商品,或者只生产了一个商品但是消费了两个商品。

 

分析二:

       上述错误现象是如何产生的呢?这里我们暂且忽略t3线程,这并不妨碍解释。我们设想这样一种情形,当t0(生产者线程,下同)线程启动,判断标记为假,打印商品名和编号,将标记置为真,唤醒其他线程(此时线程池内没有冻结线程),循环再次判断标记为真,冻结。假设下一个获取执行权的线程为t1(生产者线程,下同),判断标记为真,冻结。然后t2(消费者线程,下同)线程获取执行权,判断标记非真,打印t0线程“生产”的商品,将标记置为假,唤醒t0线程(线程池的特点,notify唤醒第一个进入线程池的线程;唤醒动作仅赋予其执行资格,执行权还是由CPU随机分配),循环判断标记为非假,冻结。这样一来具备执行资格的只有t0。t0获取到执行权,从冻结代码位置开始继续执行,生产一个商品并打印,置标记为真,唤醒t1,再次判断标记为真,冻结。t1获取执行权,从冻结代码位置开始继续执行又生产了一个商品,置标记为真,唤醒t2,冻结;此时t2分配到执行权却只能“消费”t1生产的商品,而无法消费t0生产的商品了。究其原因:生产者线程被唤醒后未能判断标记,而是直接开始“生产”商品,是导致这一错误现象产生的原因。

实现方式二:

       将判断标记的语句由if改为while,这样一来只要有任意一个生产者线程“生产”了一个商品并将标记置为真,其他生产者线程只要被唤醒不会直接执行下面的代码(“生产”商品),而是先判断标记,并再次冻结,避免了上述问题的发生。这对于消费者线程也是适用的。

代码二:只呈现修改部分的代码。

代码5:

class Resource
{
	//省略部分代码以便阅读
	public synchronized void set(String name)
	{
		//将if判断改为while判断
		while(flag)
			try{wait();}catch(Exception e){}
		//省略部分代码以便阅读
	}
	public synchronized void out()
	{
		//将if判断改为while判断
		while(!flag)
			try{this.wait();}catch(Exception e){}
		//省略部分代码以便阅读
	}
}

        关于上述问题,其实在Object类的wait方法的API文档中就有描述:对于某个参数(布尔标记flag)的版本,实现中断和虚假唤醒是可能的,而且此方法应始终在循环中使用。同时给出了wait方法的示例代码。那么通过while循环判断标记实际解决的就是虚假唤醒的问题。

分析三:

        但是,仅仅这样修改还是不够完善。设想这样的情形:t0“生产”一个商品,置标记为真,并冻结。t2获取到执行权“消费”商品后,置标记为假后冻结。t3获取执行权判断标记同样冻结。t1获取到执行权,“生产”一个商品,置标记为真,唤醒t2,循环判断标记后冻结。t2获取执行权“消费”商品,置标记为假,唤醒t3,冻结。t3获取执行权判断标记,冻结。t0获取执行权,“生产”一个商品,置标记为真,唤醒t1,判断标记冻结。最后t1获取线程判断标记也冻结,此时4个线程全部冻结。大家可以通过下图加强理解, 

注:带圆圈的数字表示线程执行的顺序。长箭头表示一次完整的执行过程。短箭头表示冻结。箭头下的序号表示唤醒的线程编号。发生这一现象是由于,唤醒线程的顺序不能是按照冻结顺序唤醒,正确的唤醒顺序应该是:生产者唤醒消费者,消费者唤醒生产者。假如某个时刻生产者全部冻结,而其中一个消费者唤醒另一个消费者,最终也是冻结,就会进入所有线程全部冻结的状态。

实现方式三:

       唤醒其他线程时使用notifyAll方法,而不使用notify方法。notifyAll方法是将所有冻结线程全部唤醒。这样即使唤醒了本方线程,判断标记后还是会冻结,而对方线程就可以正常运行。

代码三:只呈现修改部分代码。

代码6:

class Resource
{
	public synchronized voidproduce(String name)
	{
		//省略部分代码以便阅读
		this.notifyAll();
	}
	public synchronized void consume()
	{
		//省略部分代码以便阅读
		this.notifyAll();
	}
}
       最后关于线程间通信的等待唤醒机制需要提醒大家的是,一定要在同步代码内部,调用该同步代码所使用的锁对象的 wait 方法,来实现线程的冻结,如果调用了其他对象的 wait ,或者在同步代码外部调用某个对象的 wait方法,都将抛出异常。

4. JDK1.5新特性

无论是同步代码块、同步函数还是静态同步函数,都是在JDK1.5版本以前常用的线程安全机制。随着1.5新版本的出现,Java语言工程师门对这一机制进行了一次全面的更新。

4.1  Lock接口

       该接口在Java类库中的位置为:java.util.concurrent.locks。API文档中对该类描述:Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。简单理解就是该接口的基本功能与synchronized(包括方法和代码块)相同——保证线程安全,但更为强大和灵活。

       Lock接口与synchronized关键字最大的区别在于:以同步代码块为例,线程对象对于锁对象的锁定与释放动作都是隐式完成的,都是随着同步代码块的开始和结束而同时执行,换句话说,不够直观。我们从Lock接口的API文档得知,该接口有两个抽象方法——lock和unlock,分别表示锁定和释放,表明可以显示完成这两个动作。这一更新不仅提高了代码的阅读性,也提高了使用同步机制的便捷性。

       API文档中给了我们使用这个接口的伪代码示例:

class Demo
{
	/*
		ReentrantLock是Lock接口可实例化的实现类
		实际使用时该类的对象就是“锁”
	*/
	private final ReentrantLock lock = new ReentrantLock();
	//...
 
	public void m()
	{
		//在执行某段代码之前,通过调用lock方法显示地锁定锁对象。
		lock.lock();
		try
		{
			//... method body
		}
		/*
			如果同步的代码出现异常,而没有释放“锁”对象
			别的线程就会无法执行这部分代码
			因此就像释放资源一样,最终一定要释放“锁”
		*/
		finally
		{
			//通过调用unlock方法显示地释放“锁”
			lock.unlock()
		}
	}
}

4.2  Condition接口

        在描述Lock接口时,还提到了Condition接口。API文档中对它的描述是:ConditionObject监视器方法(wait、notify和notifyAll)分解成截然不同的对象,以便通过将这些对象与任意Lock实现组合使用,为每个对象提供多个等待 set(wait-set)。其中,Lock替代了synchronized方法和语句的使用,Condition替代了 Object 监视器方法的使用。

        Condition接口中定义了await、signal以及signalAll方法,分别用以替代上述三个方法。

4.3 应用示例

这里我们以前述生产者/消费者为例,说明如何实际应用上述两个接口(实际是它们的实现类),来实现多线程的同步机制。这里我们先给出代码,

代码7:

import java.util.concurrent.locks.*;
 
class Resource
{
	private String name;
	private int count = 1;
	private boolean flag = false;
 
	//通过多态地方式创建Lock接口可实例化实现类——ReentrantLock对象,此即锁对象
	privateLock lock = new ReentrantLock();
	/*
		创建Condition对象,通过该对象的await、signal以及signalAll方法
		实现等待唤醒机制
		详细说明参考代码下面说明1
	*/
	private Condition condition = lock.newCondition();
 
	/*
		方法声明中无需标识synchronized关键字
		不再使用wait、notify以及notifyAll方法实现等待唤醒机制
	*/
	//public synchronized void produce(String name)
	public void produce(String name)throws InterruptedException
	{
		//锁定“锁”对象
		lock.lock();
 
		/*
			为保证即使发生异常的情况下也能释放“锁”对象,以免其他线程无法访问
			使用try-finally格式,详细说明请参考代码下面说明2
		*/
		try
		{
			while(flag)
			//try{wait();}catch(Exceptione){}
			//通过Condition对象冻结持有lock锁对象的线程
				condition.await();
 
			this.name= name+"--"+count++;
 
			System.out.println(Thread.currentThread().getName()+"...生产者..."+this.name);
			flag= true;
			//this.notifyAll();
			//唤醒冻结线程
			condition.signalAll();
		}
		finally
		{
			//释放“锁”对象
			lock.unlock();
		}
	}
      
	//public synchronized void consume()
	publicvoid consume()throws InterruptedException
	{
		//锁定“锁”对象
		lock.lock();
 
		try
		{
			while(!flag)
			//try{this.wait();}catch(Exceptione){}
			//冻结持有lock锁对象的线程
				condition.await();
 
			System.out.println(Thread.currentThread().getName()+"..........消费者.........."+this.name);
			flag = false;
			//this.notifyAll();
			//唤醒冻结线程
			condition.signalAll();
		}
		finally
		{
			//释放“锁”对象
			lock.unlock();
		}
	}
}
class Producer implements Runnable
{
	private Resource res;
	Producer(Resource res)
	{
		this.res = res;
	}
 
	public void run()
	{
		while(true)
		{
			/*
				由于produce方法抛出异常,而run方法不能声明异常
				这里需要try-catch处理
			*/
			try
			{
				res.produce("+商品+");
			}
			catch(InterruptedException e)
			{
				//省略了异常处理代码
			}
		}
	}
}
class Consumer implements Runnable
{
	private Resource res;
	Consumer(Resource res)
	{
		this.res = res;
	}
 
	public void run()
	{
		while(true)
		{
			try
			{
				res.consume();
			}
			catch(InterruptedExceptione)
			{
				//省略了异常处理代码
			}
		}
	}
}
class ProducerConsumerDemo2
{
	public static void main(String[] args)
	{
		Resource res = new Resource();
 
		Producer pro = new Producer(res);
		Consumer con = new Consumer(res);
 
		Thread t1 = new Thread(pro);
		Thread t2 = new Thread(pro);
		Thread t3 = new Thread(con);
		Thread t4 = new Thread(con);
		t1.start();
		t2.start();
		t3.start();
		t4.start();
	}
}
运行结果与代码3(完整版)是相同的,说明新版本也完全可以实现线程间通信的等待唤醒机制,并且两者的思想与原理是相同的,只不过表现形式不同而已。这里对代码4进行两点说明:

(a)    说明1:无论从上述代码的内容,还是API文档的表述来看Condition对象的创建并不是通过“new”关键字实现的,而是通过Lock“锁”对象的newCondition获得的。实际上,这么定义的原因与wait、notify、notifyAll等方法定义在Object类是一样的。被Condition对象操作的(await、signal等)线程必定是持有“锁”对象的那个线程,那么Java工程师们的思路就是:可以直接由“锁”对象创建Condition对象,而该Condition对象操作的必定就是持有对应“锁”对象的线程。这样方便于区分操作在嵌套同步代码中执行不同同步代码的线程。

(b)    说明2:由于在JDK1.5版本中, “同步锁”的锁定与释放是显示执行的,或者说是手动执行的,因此一旦在执行同步代码过程中发生异常,就无法将“锁”正常地释放,导致其他线程无法访问这部分代码。因此,上述代码中需要被同步的代码被定义在了try代码块中,并将释放“锁”的语句定义在finally代码块中,表示一定要将锁释放。如果需要在方法内处理异常,就可以在try代码块后跟catch代码块,反之,就需要像上述代码那样将异常声明出来。

4.4  功能增强

a) 对原有生产者消费者的修改

       实际上JDK1.5版本对线程同步机制功能的增强不仅仅体现在不同的表现形式上,更为重要的是对其灵活性的改善。还是以生产者/消费者为例,代码4中当生产者线程“生产”了一个商品,且在自我冻结以前,唤醒其他所有冻结线程,以保证程序的正常运行。但实际上更为优化的做法是有针对性得唤醒对方线程,也就是说生产者唤醒消费者,或者消费者唤醒生产者,这样做可以避免在唤醒本方线程以后重复得判断标记,以此提高程序的运行效率。而1.5版本的出现使其得以实现。我们来看代码,

代码8:

class Resource
{
	private String name;
	private int count = 1;
	private boolean flag = false;
 
	private Lock lock = new ReentrantLock();
      
	/*
		创建两个Condition对象,分别用于操作生产者线程和消费者线程
		以此避免因唤醒本方线程而重复判断标记
	*/
	private Condition condition_pro = lock.newCondition();//针对生产者
	private Condition condition_con = lock.newCondition();//针对消费者
 
	public void produce(String name)throws InterruptedException
	{
		lock.lock();
 
		try
		{
			while(flag)
			//通过condition_pro对象只冻结生产者线程
				condition_pro.await();
 
			this.name = name+"--"+count++;
 
			System.out.println(Thread.currentThread().getName()+"...生产者..."+this.name);
			flag = true;
 
			//只唤醒消费者线程
			condition_con.signal();
		}
		finally
		{
			lock.unlock();
		}
	}
	public void consume()throws InterruptedException
	{
		lock.lock();
 
		try
		{
			while(!flag)
			//冻结消费者线程
				condition_con.await();
 
			System.out.println(Thread.currentThread().getName()+"..........消费者.........."+this.name);
			flag = false;
                    
			//唤醒生产者线程
			condition_pro.signalAll();
		}
		finally
		{
			lock.unlock();
		}
	}
}
上述代码中我们通过一个Lock对象获取了两个Condition对象,分别用于操作生产者和消费者线程,那么这是如何实现的呢?我们假设有t0、t1、t2以及t3四个线程,前两个为生产者,后两个表示消费者。当t0启动并开始执行,判断标记为假,“生产”一个商品并打印,置标记为真,唤醒消费者线程(此时并没有任何线程被冻结),再次判断标记为真,拥有lock“锁”对象的t0线程被condition_pro对象冻结了。此时假设t2线程获得执行权,判断标记为非真,“消费”一个商品并打印,置标记为假,然后唤醒被condition_pro对象冻结的t0生产者线程,最后再次判断标记被condition_con对象冻结。

       通过上述的方式,人为规定condition_pro对象操作生产者线程,被该对象冻结的线程,只能被同一个对象唤醒,condition_con对象同理,这样就使得原来的等待唤醒机制更具有针对性。

       上述具有针对性的线程间等待唤醒机制是无法在1.5版本以前的JDK中实现的,以生产者/消费者为例,为了对这两类线程进行不同的等待/唤醒操作势必要创建两个锁对象,但若要实现线程同步必须使用唯一的一个锁。即使使用两个锁设计一个嵌套的同步代码,虽然能保证线程安全,但又极易发生死锁问题,因此在JDK.15版本以前无法实现具有针对性的等待唤醒机制。

b)  API文档演示示例

       实际上,在Condition接口的API文档中也给出了其实现类的一个应用示例,它是利用Condition能够有针对性的进行等待唤醒操作的特点,实现了一个可阻塞的队列。所谓可阻塞的队列,它首先是一个队列,因此是一个满足元素先进先出(FIFO)的一个数据容器。该数据容器的大小是固定的,比如最多只能存储8个数据。那么之所以要实现阻塞功能是因为,这类数据容器允许多线程并发输入输出数据,换句话说,同一时间可以有多个线程存储数据,也可以有多个线程取出数据。那么这样的工作机制很容易造成线程安全问题,比如队列中存满了数据,存储线程却还在不停的存储数据导致将还未取出的数据覆盖掉,或者取出线程从空队列中取出“数据”等等。
       为了避免上述队列的线程安全问题,有必要让线程在适当的时候停止工作,也就是阻塞。比如,队列存满了数据,存储线程就应该停止存储操作,等待取出线程取出一个数据,腾出位置;或者队列中没有任何数据,此时取出线程也应停止工作,等待存储线程存储一个数据。其实这一工作原理与生产者/消费者模型是类似的,当队列存满时,令所有生产者等待,而唤醒所有的消费者;队列为空时,令所有消费者等待,而唤醒所有生产者,此时就可以利用Condition对象进行有针对性的等待唤醒操作。不过,从下面的代码中可以看出,所有的唤醒操作是每存/取一次数据就要进行的,而不是只有在生产者/消费者等待时才执行,因为我们无法得知线程在何时等待。可阻塞队列的代码如下。

代码9:

class BoundedBuffer {
	final Lock lock = new ReentrantLock();
	final Condition notFull = lock.newCondition();//控制存储线程
	final Condition notEmpty = lock.newCondition(); //控制取出线程
 
	final Object[] items = new Object[100];
	intputptr, takeptr, count;
 
	public void put(Object x) throws InterruptedException {
		lock.lock();
		try {
			while(count == items.length)
				notFull.await();
			items[putptr]= x;
			if(++putptr == items.length) putptr = 0;
			++count;
			notEmpty.signal();
		}finally {
			lock.unlock();
		}
	}
 
	public Object take() throws InterruptedException {
		lock.lock();
		try {
			while(count == 0)
				notEmpty.await();
			Objectx = items[takeptr];
			if(++takeptr == items.length) takeptr = 0;
			--count;
			notFull.signal();
			returnx;
		}finally {
			lock.unlock();
		}
	}
}

代码说明:

(1)  BoundedBuffer类的成员变量count用于记录队列中可取出数据的个数,每存储一个数据count就自增一次,每取出一个数据count就自减一次。若count等于队列长度,则令生产者等待,反之count等于0,则令消费者等待。

(2)  putptr和takeptr分别表示下次存储数据的位置,以及下次取出数据的位置,当这两个变量达到队尾时,将它们的值重新赋值为0。

(3)  我们举一个实际的运行过程。假如某一个时刻,count等于99,putptr也等于99,恰好某个存储线程获取执行权,首先判断count不等于队列长度,接着就将数据存储到了队列的最后一个位置,然后putptr自增并判断发现其值等于队列长度,因此重新赋值为0。最后令count自增(此时count为100)后,唤醒取出线程。若又有存储线程获得执行权,判断count发现等于队列长度,因此就进入等待状态。此时活动线程就只剩下取出线程了,取出线程判断count不为0后,取出一个数据,将count减1(变为99),唤醒存储线程,此时由于count不为队列长度,存储线程就又可以存储数据了。

(4)  实际上以上需求,利用一个Condition或者传统线程等待唤醒机制也是可以实现的,只不过效率较低,当有大量线程并发访问队列时,由于每次都会利用notifyAll或者signalAll唤醒所有的等待线程(无论是存储线程还是取出线程),那么总有一方是属于伪唤醒,因此伪唤醒的一方判断完标记后还要再次进入等待状态,效率非常低。

       当然如果调用notify或者signal方法,那么就容易出现所有线程均等待的问题(上文中进行过说明),因此可也是不可取的。

4.5  总结

       至此我们可以对新旧两种等待唤醒机制进行对比:

(a)    在实现同步机制时,我们不再需要为了创建锁而创建或者寻找一个不相关的对象。因为Java类库中为我们提供了专门充当“锁”的类,这也是进一步贯彻面向对象思想的举措。

(b)    其次,可以仅通过一把“锁”锁定一个共享资源的同时,还可以通过衍生的线程控制类(Condition)有区别的操作不同功能的线程,不必再像以前那样“一刀切”,在保证线程安全的同时,提高了代码的执行效率。

5. 多线程互斥与通信练习

练习一

       子线程循环10次,接着主线程循环100次,接着又回到子线程循环10次,接着再回到主线程又循环100次,如此循环50次。这里我们要利用JDK1.5版本提供的同步工具实现需求。

思路:

       这道题有两个基本要求:一是,当某个线程在执行循环的过程中,不能允许其他线程同时执行循环,也就是要保证两个循环体代码的原子性,因此这两个循环体应被同一把锁封装;二是,要实现两个线程的通信。当某个线程执行完循环以后,应通过判断标记进行自我冻结,将执行资格交给对方线程。当然在自我冻结以前,要将标记置反,并唤醒对方法线程。

定义一个资源类,在其内部成员位置上定义有锁对象(ReentrantLock)、条件对象(Condition),以及标记,此外分别定义有供子线程执行的10次循环方法,以及供主线程执行的100次循环方法,这两个方法的方法体同时被同一个锁对象封装,以保证代码的原子性。并且,这两个方法在执行循环体以前,都要通过while语句循环判断标记,如果判断为真则通过条件对象的await方法进入等待状态。每个方法在执行完循环体以后,均要将标记置反,返回彼此唤醒对方线程。代码如下。

代码10:

import java.util.concurrent.locks.Condition;
importjava.util.concurrent.locks.ReentrantLock;
 
public classThreadTest {
   
	private static class Resource {
		private ReentrantLock lock = newReentrantLock();//锁对象
		private Condition condition = lock.newCondition();//条件对象
		private boolean flag = false;//标记
       
		//10次循环方法
		public void loop10Times(int loop) {
			try{
				//上锁
				lock.lock();
               
				//判断标记是否为真
				while(flag){
					condition.await();
				}
           
				//执行循环体
				for(int i=0; i<10; i++){
					Thread.sleep(1000);
                   
					String threadName =Thread.currentThread().getName();
					System.out.println("loop" +"-" + loop + " " + threadName +" : " + (i+1));
				}
               
				//将标记置反,并唤醒对方线程
				flag = true;
				condition.signal();
			}catch(InterruptedException e){
				e.printStackTrace();
			}finally{
				//解锁
				lock.unlock();
			}
		}
             
		//100次循环方法
		public void loop100Times(int loop) {
			try{
				lock.lock();
               
				while(!flag){
					condition.await();
				}
           
				for(int i=0; i<100; i++){
					Thread.sleep(10);
                   
					String threadName =Thread.currentThread().getName();
					System.out.println("loop"+ "-" + loop + " " + threadName + " : " + (i+1));
				}
               
				flag = false;
				condition.signal();
			}catch(InterruptedException e){
				e.printStackTrace();
			}finally{
				lock.unlock();
			}
		}
	}
 
	public static void main(String[] args) {
		//由于资源对象要被内部类对象访问,因此被final修饰
		final Resource resource = new Resource();
       
		//通过匿名内部类的方式创建并启动了子线程
		new Thread(new Runnable(){
 
			@Override
			public void run() {               
				for(int i=0; i<50;i++){                   
					resource.loop10Times(i+1);
				}
			}
            
		}).start();
       
		for(int i=0; i<50; i++){
			resource.loop100Times(i+1);
		}
	}
 
}
执行后的部分结果为:

loop-1Thread-0 : 1

loop-1Thread-0 : 2

loop-1Thread-0 : 3

loop-1Thread-0 : 8

loop-1Thread-0 : 9

loop-1Thread-0 : 10

loop-1 main :1

loop-1 main :2

loop-1 main :3

loop-1 main :98

loop-1 main :99

loop-1 main :100

loop-2Thread-0 : 1

loop-2Thread-0 : 2

loop-2Thread-0 : 3

实现了两个线程交替执行循环体的需求,并且保证了循环体的原子性,没有发生线程安全问题。

代码说明:

       代码9中,我们将循环体封装为了一个资源类的成员方法,而不是直接定义在线程的run方法中。这样做不仅更好的体现了面向对象的思想,而且降低了代码的耦合性,提高了扩展性。

说他体现面向对象的思想是因为,虽然上述需求中并没有明确的操作一个共享数据,但是被不同线程执行,但却需要被同一把锁封装的两个循环体,就相当于是“共享数据”,因此这两个循环体应该是一个整体。而且既然两个循环体应被同一把锁封装,因此“锁”对象与这两个循环体也应是整体,相当于也是一个共享数据,那么最终的表现形式就是自然而然地将以上这些封装到一个资源类中(这其中也包括布尔类型的变脸,以及Condition对象,这里不再一一赘述)。由于这两个循环体已经在类的内部实现了代码的原子性,因此外部类在调用资源类对象方法时就不必再担心线程安全问题,也不必考虑如何实现的线程安全。

至于耦合性和扩展性,由于把业务逻辑代码(线程对循环体循环调用50次)和功能实现代码(两种循环体)分隔开来,更加便于后期维护和修改,试想将这两个全部混在一起将会非常混乱和臃肿,一旦需要修改代码将无所适从。因此在使用同步锁对代码进行封装时,应注意:同步锁封装的是资源类(共享数据所在类)的内部方法中,而不是线程的run方法中。

练习二

       实现三个线程的交替执行。比如,线程1先执行,然后线程2再执行,最后线程3再执行,按照这样的执行顺序循环往复。

思路:主要还是依靠Condition实现多路线程的等待唤醒。由于这里涉及三类线程,而不仅是像生产者消费者那样只有两类,因此需要创建三个Condition对象,并且标记也应该对应三个值,而不再是boolean的true/false两个值。比如,可以令标记flag为整型变量,对应1、2、3三个值,当标记为1时,执行线程1,另外两个线程等待;当标记为2时,执行线程2,另外两个线程等待,如此循环。代码如下。

代码11:

import java.util.concurrent.locks.Condition;
importjava.util.concurrent.locks.ReentrantLock;
 
public classThreeThreadCommunication {   
	//资源类
	private static class Resource {       
		private ReentrantLock lock = newReentrantLock();
       
		private Condition con1 =lock.newCondition();
		private Condition con2 =lock.newCondition();
		private Condition con3 =lock.newCondition();
       
		private int flag = 1;
       
		public void fun1() {
			lock.lock();
           
			try{
				while(flag != 1)
					try {
						con1.await();
					} catch(InterruptedException e) {
						e.printStackTrace();
					}
                
				try {
					Thread.sleep(1000);
				} catch (InterruptedExceptione) {
					e.printStackTrace();
				}
               
				System.out.println(Thread.currentThread().getName()+" fun1run.");
               
				flag = 2;
				con2.signal();
			} finally {
				lock.unlock();
			}
		}
		public void fun2() {
			lock.lock();
           
			try{
				while(flag != 2)
					try {
						con2.await();
					} catch(InterruptedException e) {
						e.printStackTrace();
					}
               
				try {
					Thread.sleep(1000);
				} catch (InterruptedExceptione) {
					e.printStackTrace();
				}
               
				System.out.println(Thread.currentThread().getName()+" fun2run.");
               
				flag = 3;
				con3.signal();
			} finally {
				lock.unlock();
			}
		}
		public void fun3() {
			lock.lock();
           
			try{
				while(flag != 3)
					try {
						con3.await();
					} catch(InterruptedException e) {
						e.printStackTrace();
					}
               
				try {
					Thread.sleep(1000);
				} catch (InterruptedExceptione) {
					e.printStackTrace();
				}
               
				System.out.println(Thread.currentThread().getName()+" fun3run.");
               
				flag = 1;
				con1.signal();
			} finally {
				lock.unlock();
			}
		}
	}
 
	public static void main(String[] args) {
		Resource resource = new Resource();
       
		//开启三个线程,分别执行fun1、fun2、fun3
		new Thread(new Runnable(){
			@Override
			public void run() {
				for (int i = 0; i < 10; i++){
					resource.fun1();
				}
			}           
		}).start();
		new Thread(new Runnable(){
			@Override
			public void run() {
				for (int i = 0; i < 10; i++){
					resource.fun2();
				}
			}           
		}).start();
		new Thread(new Runnable(){
			@Override
			public void run() {
				for (int i = 0; i < 10; i++){
					resource.fun3();
				}
			}           
		}).start();
	}
}
执行结果为:

Thread-0 fun1run.

Thread-1 fun2run.

Thread-2 fun3run.

Thread-0 fun1run.

Thread-1 fun2run.

Thread-2 fun3run.

代码说明:

       我们简单说明一下,代码11的执行原理。标记flag公有1、2、3三个值,初始化为1。启动三个线程以后,无论谁先获取到执行权,都要判断标记是否是本线程能够执行的那个值。由于标记初始化为1,因此线程1和线程2都会因为标记不满足而分别被con2和con3冻结。线程0在执行完毕之前,将标记置为2,并唤醒被con2冻结的线程,也就是线程2,之后线程1再次判断标记被con1冻结。同样线程1被唤醒后,在执行完毕以前将标记置为3,唤醒被con3冻结的线程2,而自己则再次判断标记冻结,如此循环往复,即可实现3路线程的交替等待唤醒。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值