java实践3之大话线程、手写线程池、理解核心参数

java实践3之大话线程、手写线程池、理解核心参数

一、创建线程和使用

在java中,创建一个线程,可以继承Thread,也可以实现 Runnable 接口。然后重写run方法,或者实现Callable接口实现call方法。(使用demo,大家自行查找)
在使用中一般需要知道下面的几个知识点。

1、启动调用

注意start()方法和run()方法。start才是开启线程,run方法只是普通的方法,是没有多线程效果的。当调用start()启动线程后,并不是马上就可以运行,必须得等CPU来处理,当线程被调度并获得CPU资源时,才会运行。(这个在培训的时候早就说过了,还用你来说?大家别着急先记一下这俩方法,后面线程池会说道,线程池执行的run,而不是start)
实现Callable接口的话。执行Callable任务后,可以获取一个Future的对象,调用它的get方法可以获取返回值,注意get方法是阻塞的,任务不完成,get会一直阻塞,不会继续向下执行。它是如何获取返回值呢?面试事为什么有时候会说,实现线程就Thread和Runnable2种呢?下面线程池原理会提到,相信大家就会有答案了。

2、volatile关键字

volatile关键子,这个就比较重要的了,它的主要作用是保证变量的可见性。在多线程编程时经常会用到,尤其是在多个线程访问静态变量时,如果不理解volatile,则特别容易出错。
执行下面代码:a线程访问变量flag,b线程也访问变量flag。

public static boolean flag = false;
public static void main(String[] args) throws Exception {
	new Thread(new Runnable() {
		@Override
		public void run() {
			// 监控下面的线程是否执行完毕,否则一直监控,
			// 下面线程执行完后,则停止
			while (!flag) {}
		}}).start();
	Thread.sleep(10);
	new Thread(new Runnable() {
		@Override
		public void run() {flag = true;}
	}).start();
}

我们可以运行一下,看到程序陷入了死循环,为什么会死循环呢,b线程明明已经更改了flag,a线程得到最新数据,判断后应该停止啊? 先看一下java内存模型抽象图
java内存模型抽象图:
在这里插入图片描述
线程a启动时会从主内存中拷贝flag到此线程的内存中做副本。线程b启动时也是从主内存中拷贝flag,到此线程的内存中做副本。 当线程b修改flag时,先修改的是本地的flag副本,然后再同步到共享变量。
为何线程要做副本呢?这么做的好处是什么,我理解为,我们一般做优化时,例如:一些不太重要的数据计算,其实也是从远端,同步一份最新数据,放到本机做副本,然后实时计算,定时再向上推送一批数据的。减少本地和外部交互次数,能解决IO、连接等资源。
刚好jvm中的优化器也是这么想的,JNI会对我们的程序进行优化,由于每个线程都有自己的工作内存,为了加快cpu运行速度,不用每次都去主内存中去查询修改,它会把flag拷贝到本线程中做副本。所以最终会造成程序“错误”。
这里怕误导大家,最终造成程序 “错误”,我写的是“错误”,不是死循环,单纯的运行demo会死循环,有的文章中说demo中线程1运行一定次数后,JNI会判定为热点代码,while (!flag) {}相当于while(true){}。这个我不太赞同,因为没看过底层源码不敢瞎说,如果在循环体中增加一些语句,则程序就会退出循环,但何时退出,所以我认为不使用volatile会总成程序则不可控。
所以我认为多线程访问共享变量,不是死循环问题,而是要注意不加volatile可能会造成程序运行错误,并且程序不可控等问题。

volatile可见性:使用volatile修饰flag后,无论线程a或b访问本地副本时,都会先去同步主内存中的flag,然后再进行操作,操作完成后也会马上同步到主内存。这就是volatile的保证变量的可见性。注意:a线程->flag副本->主内存共享flag ,其中副本到住内存是原子性的、没有并发问题,但是a线程到flag副本不是原子性的,所以volatile只是保证可见性,并不保证并发问题。
volatile防止指令重排: 定义 int a=1;int b=1;编译器有可能会优化成int b=1;int a=1;有依赖的话,如果改变顺序程序会出错,所以它不会去优化。这点我觉着不重要。实际应用中我没碰到过因代码重排而出现的问题,也没听说过谁在生产系统中因为指令重排导致了问题。
另外:除了volatile外,也可以为flag属性增加update方法,用synchronized修饰此方法。更改时通过update方法来修改flag的值。由于使用了synchronized,也可以保证变量的可见性,由于synchronized比较重所以建议优先尝试volatile。

3、yeild()和join()

yield:表示这个线程不着急,它自己可以先等等,让其他的线程先运行,当前线程可能会暂停,也可能不会。
join:内部调用了wait(等待)方法,wait(等待)多久可以设置,调用此方法会阻塞,一直等到这个线程完成,再向下运行。例子:

Thread t=new Thread(new Runnable() {
	@Override
	public void run() {
		try {
			System.out.println("子线程开始执行");
			Thread.sleep(1000);
			System.out.println("子线程执行完毕");
		} catch (Exception e) {
			e.printStackTrace();
		}
	}});
t.start();
t.join();//内部调用了wait,进行阻塞,执行完t线程后才会向下执行,如果不调用join,则不阻塞,继续执行下面的语句
System.out.println("主线程执行完毕");

一般能用到的场景为:假如2个线程A和B,必须等到A执行完,B才可以执行,可以尝试jion方法。如果有这种依赖的场景的话,我会把A、B合并,串行处理。如果有多个线程依赖的话,我会用futrue的get方式。

4、其他

其他还有一些我们可能会用到的方法,比如:
1、setName(设置线程名称)
2、setPriority(更改线程优先级)
3、sleep(让此线程休眠),
4、wait(当前线程进入等待,等待nofity唤醒,需要和notify和nofityall连用,或者设置等待时间)
5、notify(唤醒正在等待此对象监视器的单个线程)
6、notifyAll(唤醒正在等待此对象监视器的所有线程)
知道一下即可,用的不是很多。

二、线程池

1、为什么使用线程池

开发中,在使用多线程处理业务时,我们常常会用到多线程技术。线程每次的创建、销毁、线程之间的切换都是需要耗费资源的。当线程过多时,而对应的CPU和CPU核心数没有增加的话,就会造成资源浪费、系统死机、程序缓慢等一系列问题。
比如我是餐厅的老板(服务器),如果每来一桌客人(每一次请求),都雇佣个服务员来处理 (创建线程),这样是需要花大量的金钱的(耗费资源),在厨师(CPU)就一位的情况下,服务员越多耗费的工资就得多开(小号资源多),厨师也得来回处理看每个服务员的菜单 根据菜的快慢决定出菜顺序,太慢的放后 (线程切换),也不会提升效率,并且当客人减少时,我们要辞掉服务员 是要赔偿N+1的(耗费资源)。
这时我们就要使用线程池技术,来管理优化多个线程,避免线程多次创建、销毁和减少线程之间的切换,造成的一些问题。

2、创建线程池5种类型

CachedThreadPool 可缓存线程池:特点是无限大,同时提交1w个任务就创建1w个线程,提交多少就创建多少个线程执行,只要服务器资源够。个人认为哈这个没用,完全没解决问题,放弃。
FixedThreadPool 定长线程池:这个是比较常用的,不会像CachedThreadPool一样,同时来多少就创建多少,它是根据初始化的入参,去创建固定大小的线程,去执行任务的。入参设置5的话,那么他同时跑的线程就5个,同时提交超过5个的话,多余的任务都缓存在队列中。
SingleThreadExecutor 单线程池:同时只有1个线程运行,不知道这个什么用,使用Executors.FixedThreadPool(1)就可以了。
ScheduledThreadPool 任务调度线程池: 可设置任务运行时间间隔的线程池。
newWorkStealingPool 工作窃取线程池: 这个没用过不了解。

注意:使用FixedThreadPool时,队列可容纳的任务为int的最大值,任务比较多也会造成内存溢出,可以直接使用ThreadPoolExecutor。

3、线程池核心参数和原理

线程池的核心参数是什么?线程池是怎么保证线程不用频繁创建、销毁线程的,我们提交任务到线程池的时候,他内部不也要start启动线程吗?我们使用Fixed线程池时,放到队列中,它是先启动几个任务线程任务,等运行完了,销毁刚才的线程任务,再启动几个吗?如果是的话,那他这个只是限流了啊,没有减少线程创建销毁啊?只是进行了限流,该干的一点没少啊。
这些问题面试多线程时经常会被问到,怎么办?赶紧背吧,什么核心参数啊,什么源码啊,赶紧背一遍,然而第二天又忘了,内容实在是太多了。其实理解了他们之后,就很容易了,不需要背。

3.1、线程池是如何保证线程频繁创建和销毁的?

1)首先向线程池提交任务。调用execute方法。(这不是重点)
2)execute这里调用了addworker 传入我们新建的任务。(这也不是重点)
在这里插入图片描述
3)然后addWorker内部,创建了worker对象。这里要注意它启动的是woker的thread线程,而不是把我们提交的任务start。(这个是重点,它启动的是他自己创建的线程,不是我们提交的,我们提交的叫任务不叫线程
在这里插入图片描述
4)在来看一下worker对象,在3)中启动的是woker的线程,然后调用runWorker方法。
在这里插入图片描述
5)最后看一下runworker,可以看到它执行了我们提交的task任务,注意上面说的线程启动调用。这里他调用了任务的run方法,根本就没启动。(这个也很重要)
在这里插入图片描述
通过上面就理解了:首先我们提交到线程池的叫做任务,而不是线程,当时开发人员估计是为了方便所以直接沿用了Runable。线程池它自己会创建线程,然后start启动他自己创建的线程,来执行我们提交的任务run方法,执行完run后继续从队列Queue中取任务来执行。没任务则阻塞 或者结束线程。
举个例子,假设 有10个任务提交到线程池,线程池设置为2. 那么线程池,内部会创建2个线程启动,他们分别会从队列中取出来任务(一个一个取,取一个执行一个,完毕再取下一个),来执行run方法(不是start启动),。
理解了线程池的原理,也就理解了Callable获得的返回值,如何实现了吧。它就是套了一个Thread,然后start开启这个thread,在这个thread的run方法中执行我们的call方法,get的时候会判断线程是否执行完毕,未完毕继续等,完了的话就返回call的结果。

3.2、核心参数

这个核心参数的话,我是这样理解的。知道了线程池的原理,假如自己实现线程池,需要关注哪些。推理一下大概就知道了。

  1. 线程池可接收任务的总大小 得关注,不能来几亿个任务都放队列里吧,应该设置大小,超过,就不应该接收了。否则会内存溢出。
  2. 还有执行的任务线程数 得关注吧,不能同时执行开启几千几万个线程吧, cpu就那么几个核数。也就是一共就几个人,弄几万个项目同时干,来回切换项目也很麻烦(线程切换),这也不现实。
    我认为这2个参数就是核心了。其他就是一些就看具体业务了,我认为不是很重要,比如woker一直没有任务,要不要回收,是否允许增加一些冗余线程(比如zf机构不也有一些临时工嘛,正式员工比较少,一般会配备几个临时工,完事直接回收),线程运行时间要不要做控制,要不要做超时处理等等。
3.3、CountDownLatch使用

在实际场景中CountDownLatch也经常会使用,它就是一个线程安全的计数器,例如:有a、b、c 3个线程,c线程需要等a和b完成后,才能执行。那么就需要这个计数器了。
例子:

ExecutorService es = Executors.newFixedThreadPool(3);
//初始化一个计数器初始值为3
final CountDownLatch latch = new CountDownLatch(3);
for (int x = 0; x < 3; x++) {
	es.submit(new Runnable() {
		public void run() {
			latch.countDown();// 做完了计数器-1
		}});
}
// 等待计数器归0,所有都执行完成后继续向下执行
latch.await();
// 注意调用shutdown后,不在接收新的任务,内部任务完成后,
// 会关闭池子里的那些线程并停止。
// 否则池子里的那些worker线程会一直阻塞,等待新的任务提交。
es.shutdown();
System.out.println("完成");

这个的原理也很简单,我们自己弄个变量也可以。
1)、初始化值为线程数,
在这里插入图片描述
2)、定义自减方法
在这里插入图片描述
3)使用cas的方式(也可以使用其他方式),去监控latch的值,当latch=0时,说明所有线程运行完了,则退出循环。
在这里插入图片描述
完整demo:

public final class Test {
	// 定义个计数器
	public static int latch;// 标识
	public static void main(String[] args) throws Exception {
		int nums = 3;// 线程数量
		latch = nums;
		ExecutorService es = Executors.newFixedThreadPool(nums);
		for (int x = 0; x < nums; x++) {
			es.submit(new Runnable() {
				public void run() {
					try {
						// 做一些事情
						Thread.sleep(3000);
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
					countDown();// 做完了计数器-1
				}});}
		// 等待计数器归0,所有都执行完成后继续向下执行
		await();
		// 注意调用shutdown后,不在接收新的任务,内部任务完成后,
		// 会关闭池子里的那些线程并停止。
		// 否则池子里的那些worker线程会一直阻塞,等待新的任务提交。
		es.shutdown();
		System.out.println("完成;latch="+latch);
	}
	public static synchronized void countDown() {
		latch--;
	}
	public static void await() throws Exception {
		Field f = Unsafe.class.getDeclaredField("theUnsafe");
		f.setAccessible(true);
		Unsafe unsafe = (Unsafe) f.get(null);
		long offer = unsafe.staticFieldOffset(Test.class.getDeclaredField("latch"));
		int result = -1;
		boolean flag = false;
		while (!flag) {
			flag = unsafe.compareAndSwapInt(Test.class, offer, 0, result);
		}
	}
}

4、实现自己的线程池

那么现在我们来实现以下自己的线程池
1、定义个接口,让其他任务实现这个接口,helloword中执行 要执行的方法。
public interface TaskInterface { public void helloword(String treadname); }
2、定义核心线程,数组和队列,队列存放要执行的任务,数组存放执行任务的线程
Thread[] hexinthread = new Thread[workerNums];//工作线程数
BlockingQueue bufqueue = new ArrayBlockingQueue(maxTaskLen);;//执行任务的队列

3、开启我们的线程
4、 对外提供add接口,让上游可以添加任务
public void addTask(TaskInterface tic) {
bufqueue.offer(tic);
}
大家先想一下思路自己实现一下。
完整demo
接口

public interface TaskInterface { 	
public void helloword(String treadname);
 }

实现类

public class TaskInterfaceImp implements TaskInterface {  	
public void helloword(String treadname) { 		
System.out.println(treadname + "执行" ); 	
}
}

自定义线程池

public class SelfThreadChi {
	Thread[] hexinthread = null;
	BlockingQueue<TaskInterface> bufqueue = null;
	int workerNums;
	int maxTaskLen;
	//任务计数器
	AtomicInteger jishuqi = new AtomicInteger(0);

	// 让用户初始化时 填入工作线程数 和 可接收的任务数 多了就拒绝
	public SelfThreadChi(int workerNums, int maxTaskLen) {
		this.workerNums = workerNums;//
		this.maxTaskLen = maxTaskLen;
		hexinthread = new Thread[workerNums];// 初始化
		bufqueue = new ArrayBlockingQueue<TaskInterface>(maxTaskLen);// 初始化

		for (int i = 0; i < workerNums; i++) {
			String name = "线程" + i;
			// 新建new一个我们自己的thread 从队列中取任务,然后执行helloword方法
			hexinthread[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					while (true) {
						try {
							// 从队列中取 ,没有就阻塞,有就执行下面(可以使用其他方式 这里只是简单举个例子)
							TaskInterface ti = bufqueue.take();
							// 取完了就开始执行
							ti.helloword(name);
							// 队列中的任务计数器 -1
							jishuqi.decrementAndGet();
						} catch (InterruptedException e) {
							// TODO Auto-generated catch block
							e.printStackTrace();
						}
					}

				}
			});
			// 启动我们自己的线程
			hexinthread[i].start();
		}
	}

	// 接收外部传过来 要执行的任务
	public void addTask(TaskInterface tic) {
		// 队列没超过边界则添加到执行队列中,等待执行
		if (jishuqi.get() < maxTaskLen) {
			// 计数器+1
			jishuqi.incrementAndGet();
			bufqueue.offer(tic);
		} else {
			System.out.println("没添加成功,任务队列满了");
		}
	}

}

main函数

public static void main(String[] args) {
		SelfThreadChi stc = new SelfThreadChi(5, 20);
		for (int i = 0; i < 50; i++) {
			stc.addTask(new TaskInterfaceImp());
		}
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println("刚才满了,有一些没添加成功,等一下执行完毕,就可以再次添加了");
		for (int i = 0; i < 10; i++) {
			stc.addTask(new TaskInterfaceImp());
		}
	}

总结

线程我觉着要知道并理解有几点:
1、线程的创建、启动。继承Thread 实现Runable或Callable带返回值。
2、volatile保证变量的可见性。
3、线程池FixedThreadPool的使用时,不断提交任务会有内存溢出的风险, ThreadPoolExecutor如何使用。
4、理解线程池原理,通过自己创建少量的线程,执行外部提交任务的方式,来保证线程不会频繁创建、销毁、上下文切换的。还有一些例如:shutdown、isShutdown、awaitTermination等等线程池的方法,有个印象,用的时候查一下API即可。
5、还有一些锁这个还没整理,以后有机会继续分享

好了这篇分享完了,希望对大家会有帮助,文章中有哪些错误,或者理解的不对,欢迎大家多多和我交流、批评和指正。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值