黑马程序员——多线程10:多线程相关练习

本文通过三个练习详细探讨了多线程的并发控制问题。练习一介绍了如何使用线程池和阻塞队列实现四线程并行打印日志,使程序运行时间缩短。练习二讨论了如何保证10个消费者线程按顺序消费数据,每个消费者每秒处理一个数据。练习三展示了如何处理相同key的线程互斥执行,确保按顺序输出时间值。文章通过不同代码示例,解析了线程同步和互斥的实现方法,包括使用线程池、阻塞队列、同步锁以及线程安全集合。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

------ Java培训、Android培训、iOS培训、.Net培训、期待与您交流! -------

练习一

题目:

       现有的程序代码模拟产生了16个日志对象,并且需要运行16秒才能打印完这些日志,请在程序中增加4个线程去调用parseLog()方法来分头打印这16个日志对象,程序只需要运行4秒即可打印完这些日志对象。

原始代码:

public class Test {
	public static void main(String[] args){
              
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		/*
		 * 模拟处理16行日志,下面的代码产生了16个日志对象,
		 *当前代码需要运行16秒才能打印完这些日志。
	 	 * 修改程序代码,开四个线程让这16个对象在4秒钟打完。
	 	 */
		for(int i=0;i<16;i++){  //这行代码不能改动
			final String log = ""+(i+1);//这行代码不能改动
			{
				Test.parseLog(log);
			}
		}
	}
             
	//parseLog方法内部的代码不能改动
	public static void parseLog(String log){
		System.out.println(log+":"+(System.currentTimeMillis()/1000));
                    
		try{
			Thread.sleep(1000);
		}catch (InterruptedException e) {
			e.printStackTrace();
		}           
	}    
}
我的思路:

       可以创建固定线程数量的线程池,将线程数量固定为4个。在for循环内,将每个调用Test静态方法parseLog的语句封装为一个Runnable对象,然后提交到线程池中。那么循环结束相当于向线程池中提交了16个任务。这16个任务将交由4个线程完成,那么每个线程每次执行执行一个任务,4个线程同时执行4个任务,由此实现了一秒钟打印4行日志,4秒钟打印16条日志的需求。代码如下。

代码1:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class Test {
 
	public static void main(String[] args) {
       
		System.out.println("begin:"+(System.currentTimeMillis()/1000));       
		ExecutorService threadPool = Executors.newFixedThreadPool(4);
       
		/*
		 * 模拟处理16行日志,下面的代码产生了16个日志对象,
		 *当前代码需要运行16秒才能打印完这些日志。
		 * 修改程序代码,开四个线程让这16个对象在4秒钟打完。
		 */
		for(int i=0;i<16;i++){  //这行代码不能改动
			final String log =""+(i+1);//这行代码不能改动
			{
				threadPool.execute(newRunnable(){
 
					@Override
					public void run() {
						Test.parseLog(log);
				}});
			}
		}
       
		threadPool.shutdown();
	}
   
	//parseLog方法内部的代码不能改动
	public static void parseLog(String log){
		System.out.println(log+":"+(System.currentTimeMillis()/1000));
       
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}      
	}
}
张孝祥老师的思路:

       创建一个长度为4的阻塞队列。那么每次在for循环内创建一个新的log对象,就调用put方法存储到队列中。开启四个线程(这里不使用线程池),每个线程从队列中取出一个元素,然后调用parseLog方法将该log打印到控制台,这样就能实现每次从队列中取出4个元素并打印的效果。代码如下。

代码2:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
 
public class Test {
 
	public static void main(String[] args){
		final BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1);
		for(int i=0; i<4; i++){
			new Thread(new Runnable(){
 
				@Override
				public void run() {
					while (true) {
						try {
							String log =queue.take();
							parseLog(log);
						} catch(InterruptedException e) {
							e.printStackTrace();
						}
					}
				}
               
			}).start();
		}
       
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		/*
		 * 模拟处理16行日志,下面的代码产生了16个日志对象,
		 *当前代码需要运行16秒才能打印完这些日志。
		 * 修改程序代码,开四个线程让这16个对象在4秒钟打完。
		 */
		for(int i=0;i<16;i++){  //这行代码不能改动
			final String log = ""+(i+1);//这行代码不能改动
			{
				try {
					queue.put(log);
				} catch (InterruptedExceptione) {
					e.printStackTrace();
				}
			}
		}
    	}
   
	//parseLog方法内部的代码不能改动
	public static void parseLog(String log){
		System.out.println(log+":"+(System.currentTimeMillis()/1000));
       
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}      
	}
}

       由于在打印log之后都要令本线程睡眠1秒钟,因此parseLog是以上代码执行速度的关键所在。那么无论是将打印log的方法封装为任务提交到线程池中,还是将log本身存储到队列中,子线程在执行打印log操作以前都会几乎同时开始睡眠(可能相差几毫秒,但是对于1秒钟睡眠时间而言都是可以忽略不计的),然后同时唤醒并打印,因此睡眠1秒钟是这个程序执行速度的瓶颈。那么阻塞队列的长度就可以设置为任意值,甚至是1,因为无论存取操作都是瞬间完成的,瞬间存储一个元素后,就有线程瞬间取走该元素,然后开始等待一秒钟,最终的效果同样是4个线程在取到元素后几乎同时开始等待。大家可以自行修改程序观察执行结果。当然长度设置大于4也是可以的,原理相同不再赘述。

       总的来说,本题主要需要解决的是,如何控制四个线程在打印log之前能够同时开始等待1秒钟。因此调用parseLog方法的代码一定要定义在线程的run方法中,上述两种思路均是这样实现的。

练习二

题目:

       现成程序中的Test类中的代码在不断地产生数据,然后交给TestDo.doSome()方法去处理,就好像生产者在不断地产生数据,消费者在不断消费数据。请将程序改造成有10个线程来消费生成者产生的数据,这些消费者都调用TestDo.doSome()方法去进行处理,故每个消费者都需要一秒才能处理完,程序应保证这些消费者线程依次有序地消费数据,只有上一个消费者消费完后,下一个消费者才能消费数据,下一个消费者是谁都可以,但要保证这些消费者线程拿到的数据是有顺序的。

原始代码:

public class Test {
 
	//不能改动此TestDo类
	static class TestDo {
		public static String doSome(String input){
                    
			try{
				Thread.sleep(1000);
			}catch (InterruptedException e) {
				e.printStackTrace();
			}
			String output = input + ":"+ (System.currentTimeMillis() / 1000);
			return output;
		}
	}
 
	public static void main(String[] args) {
                    
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		for(int i=0;i<10;i++){  //这行不能改动
			String input = i+"";  //这行不能改动
			String output = TestDo.doSome(input);
			System.out.println(Thread.currentThread().getName()+":" + output);
		}
	}
}
我的思路:

       由于要保证数据的打印顺序与数据的产生顺序是一致的,因此可以想到使用队列将数据缓存起来。那么在打印数据以前,将产生的数据存储到队列中,然后创建一个包含有10个线程的线程池,每存储一个数据,就将取出数据并打印的操作封装为一个Runnable对象,提交给线程池执行。注意要将数据的存储与数据取出任务的提交代码写到一个循环体内,否则如果先开启一个循环将10个数据一次性全部存储到队列中,然后再开启一个循环提交任务,就无法实现每秒钟打印一个数据的需求,而是将10个数据同时打印出来了。代码如下。

代码3:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class Test {
   
	//不能改动此TestDo类
	static class TestDo {
		public static String doSome(String input){
           
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			String output = input + ":"+ (System.currentTimeMillis() /1000);
			return output;
		}
 
	}
 
	public static void main(String[] args) {
		final BlockingQueue queue = new ArrayBlockingQueue(10);
		ExecutorService threadPool = Executors.newFixedThreadPool(10);
       
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		for(int i=0;i<10;i++){  //这行不能改动
			String input = i+"";  //这行不能改动
			String output = TestDo.doSome(input);
           
			try {
				queue.put(output);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
           
			threadPool.execute(new Runnable(){
 
				@Override
				public void run() {
					String output = null;
					try {
						output = (String)queue.take();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					System.out.println(Thread.currentThread().getName()+ ":" + output);
				}
               
			});
           
		}
       
		threadPool.shutdown();
   	 }
}

       同样,以上代码的执行瓶颈在于TestDo类的doSome方法,每形成一个output数据以前,都要等待1秒钟,那么由于output数据的形成以及Runnable任务的提交同在一个循环体中,因此线程池中每秒只会接收一个任务,也就是每秒钟只能有一个线程从队列中取出一个数据,从而实现了开启10个线程,而每秒只打印一个数据的需求。因此队列的长度同样可以使任意值,比如1,即使这样每存储一个元素后,就有一个线程瞬间将数据取出并打印,此后又要等待1秒钟重复同样的动作。

张孝祥老师的思路:

       张老师使用了一个新的阻塞队列,称为SynchronousQueue。该队列的特点是没有长度的概念,实际上没有数据的存储或者取出操作。数据的存储操作只有在取出操作执行的同时瞬间执行,否则如果队列的取出操作没有被调用,那么存储操作也就会阻塞。

       实现需求的主要思路是,开启10个线程,每个线程尝试从队列中取出元素,取到一个input数据后就将该数据传递至Test的doSome方法中,返回一个output,然后打印。与此同时,再开启另一个循环,不断地向队列中存储input数据。但要注意,由于此时阻塞队列相当于是一个多线程并发访问的共享数据,因此有必要把每个线程run方法中从队列取出元素的代码进行同步处理。这样一来每个时间段内,只能有一个线程访问队列,而且由于doSome方法睡眠一秒的作用,即可实现每个线程每秒按顺序打印一个数据的需求。代码如下。

代码4:

import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.locks.ReentrantLock;
 
public class Test {
   
	//不能改动此TestDo类
	static class TestDo {
		public static String doSome(String input){
           
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			String output = input + ":"+ (System.currentTimeMillis() /1000);
			return output;
		}
	}
 
	public static void main(String[] args) {
		SynchronousQueue<String> queue = newSynchronousQueue<String>();
		ReentrantLock lock = new ReentrantLock();
       
		for(int i=0; i<10; i++){
			new Thread(new Runnable(){
 
				@Override
				public void run() {
					String output = null;
                   
					lock.lock();
                   
					try {
						String input =queue.take();
						output =TestDo.doSome(input);
					} catch(InterruptedException e) {
						e.printStackTrace();
					} finally {
						lock.unlock();
					}
                   
					System.out.println(Thread.currentThread().getName()+ ":" +output);
				}
               
			}).start();
		}
       
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		for(int i=0;i<10;i++){  //这行不能改动
			String input = i+"";  //这行不能改动
           
			try {
				queue.put(input);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
   	}
}

       代码4中利用ReentrantLock实现了同步锁的功能,除此以外,还可以利用只含有一个许可的Semaphore对象充当锁的角色,只需将lock.lock()和lock.unlock()替换为semaphore.acquire()和semaphore.release()即可,原理是一样的。

       实际上,要想做出这道题,需要解决两个关键问题:按顺序打印,以及每次只能由一个线程进行打印操作。那么按顺序打印就要利用队列来扮演缓存的角色。至于解决每次只能由一个线程进行打印操作的需求,这就要保证每一秒只能有一个线程访问队列。我的思路是利用doSome方法的一秒睡眠控制Runnable任务提交的速度,既然一秒钟只能向线程池中提交一个任务,那么也就相当于一秒钟只能有一个线程访问队列,并取出元素。张老师的思路是,利用同步锁,控制每次只能有一个线程访问队列,而且一次访问时间要持续一秒钟,换句话说,同步锁的锁定时间要持续一秒,这同样也实现了一秒钟只有一个线程访问队列的需求。

练习三

题目:

       现有程序同时启动了4个线程去调用TestDo.doSome(key,value)方法,由于TestDo.doSome(key, value)方法内的代码是先暂停1秒,然后再输出以秒为单位的当前时间值,所以,会打印出4个相同的时间值,如下所示:

4:4:1258199615

       1:1:1258199615

       3:3:1258199615

       1:2:1258199615

请修改代码,如果有几个线程调用TestDo.doSome(key, value)方法时,传递进去的key相等(equals比较为true),则这几个线程应互斥排队输出结果,即当有两个线程的key都是"1"时,它们中的一个要比另外其他线程晚1秒输出结果,如下所示:

       4:4:1258199615

       1:1:1258199615

       3:3:1258199615

       1:2:1258199616

总之,当每个线程中指定的key相等时,这些相等key的线程应每隔一秒依次输出时间值(要用互斥),如果key不同,则并行执行(相互之间不互斥)。

原始代码:

//不能改动此Test类
public class Test extends Thread{
             
	private TestDo testDo;
	private String key;
	private String value;
 
	private static class TestDo {
 
		private TestDo() {}
		private static TestDo _instance = new TestDo();
 
		public static TestDo getInstance() {
			return _instance;
		}
 
		public void doSome(Object key, String value) {
      
			//以大括号内的是需要局部同步的代码,不能改动!
			{
				try{
					Thread.sleep(1000);
					System.out.println(key+":"+value+ ":"
						+(System.currentTimeMillis() / 1000));
				}catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
 
	public Test(String key,String key2,String value){
		this.testDo = TestDo.getInstance();
		/*
		 * 常量"1"和"1"是同一个对象,下面这行代码就是要用"1"+""的方式产生新的对象,
		 *以实现内容没有改变,仍然相等(都还为"1"),但对象却不再是同一个的效
		 */
		this.key = key+key2;
		this.value = value;
	}
 
	public static void main(String[] args) throws InterruptedException{
		Test a = new Test("1","","1");
		Test b = new Test("1","","2");
		Test c = new Test("3","","3");
		Test d = new Test("4","","4");
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		a.start();
		b.start();
		c.start();
		d.start();
 
	}
             
	public void run(){
		testDo.doSome(key,value);
	}
}
思路:

       由于doSome方法要在相同key的线程之间实现互斥效果,必然要使用同步锁,而且这个同步锁还不能是通过new关键字创建而来的,否则所有线程都将互斥。那么自然而然就想到可以将每个线程的key作为同步锁,这样一来相同key的线程之间自然就是互斥的,而对不同key的线程没有任何影响。

       但是这里还不能直接在doSome方法体外嵌套一层将锁指定为参数key的synchronized代码块,原因是在Test类的构造方法中进行了“this.key = key + key2;”的处理。如果是在字符串常量之间之间进行串联操作,通常编译期间编译器就会将其进行优化直接得出串联后的字符串。如果以上述方式串联得到的新字符串内容相同,实际也就是同一个字符串对象。比如,下面的两行代码。

str1 = "1"+ "";

str2 = "1"+ "";

经编译器优化后,在执行时实际上是,

str1 = "1";

str2 = "1";

因此,无论是str1 == str2还是str1.equals(str2),返回结果都是true。但是在以上原始代码中,字符串的串联操作发生在两个字符串变量之间,编译器就不会对其进行优化,因为在编译时期编译器并不知道变量的具体值,那么在运行期间串联后的字符串即使内容相同,也是两个不同的字符串对象,相当于是通过new关键字创建出来的字符串对象。既然是两个不同的对象,直接将其作为同步锁当然也就无法互斥了。

       上述问题的解决方法是在TestDo的成员位置上定义一个用于存储每个线程key的容器,每调用一次doSome方法,首先就要判断key容器中是否已经包含有本线程key。由于contains底层使用equals方法比较两个字符串,因此避免了上述字符串串联的问题。如果key容器中包含有本线程key,则说明之前已经有线程使用了同一把锁(也就是key),此时就要从集合中将相同的老key取出,而不使用本线程的key;反之,直接将本线程key存储到集合中,然后将此key定义为同步锁。

       还有最后一个问题需要解决。由于key容器会被多个线程并发访问,相当于是一个共享数据,因此有必要对操作key容器的代码进行同步处理,当然这个时候可以使用同一把锁,将这部分代码封装起来。但是更为简便的方法是使用线程安全的集合,比如CopyOnWriteArrayList。最终的代码如下。

代码5:

import java.util.concurrent.CopyOnWriteArrayList;
 
public class Test extends Thread {
 
	private TestDo testDo;
	private String key;
	private String value;
 
	private static class TestDo {
 
		private TestDo() {}
		private static TestDo _instance = new TestDo();
       
		//用于存储不同线程key的线程安全集合
		private CopyOnWriteArrayList keys = new CopyOnWriteArrayList();
 
		public static TestDo getInstance() {
			return _instance;
		}
 
		public void doSome(Object key, String value) {
			//判断集合中是否已经包含此key
			if(keys.contains(key))
				//若已包含,则取出原有key,将其作为锁
				key = keys.get(keys.indexOf(key));
			else
				//若不包含,则将key存储到容器中,将其作为锁
				keys.add(key);
           
			// 以大括号内的是需要局部同步的代码,不能改动!
			synchronized(key)
			 {
				try {
					Thread.sleep(1000);
					System.out.println(key+":"+value + ":" +
						(System.currentTimeMillis()/ 1000));
				} catch (InterruptedExceptione) {
					e.printStackTrace();
				}
			}
		}
	}
 
	public Test(String key,String key2,String value){
		this.testDo = TestDo.getInstance();
		/*
		 * 常量"1"和"1"是同一个对象,下面这行代码就是要用"1"+""的方式产生新的对象,
 		 * 以实现内容没有改变,仍然相等(都还为"1"),但对象却不再是同一个的效
 		 */
		this.key = key+key2;
		this.value = value;
	}
 
	public static void main(String[] args) throws InterruptedException{
		Test a = new Test("1","","1");
		Test b = new Test("1","","2");
		Test c = new Test("3","","3");
		Test d = new Test("4","","4");
		System.out.println("begin:"+(System.currentTimeMillis()/1000));
		a.start();
		b.start();
		c.start();
		d.start(); 
	}
       
	public void run(){
		testDo.doSome(key, value);
	}
}
执行结果为:

begin:1441180191

1:1:1441180192

4:4:1441180192

3:3:1441180192

1:2:1441180193

由此实现了相同key线程间的互斥效果。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值