JAVA SE 实战篇 C1 多线程编程基础

P1 关于多线程编程的几个知识点

1 进程

(1) 进程是动态的

进程与程序之间存在密切的关系,程序是静态的,存放在磁盘中,一旦写好就不会再发生变化,而进程指的是程序从磁盘加载到内存中,并享受CPU服务的动态过程

操作系统需要“创建”进程,为其分配一定的资源才能存在,进程存在期间,需要操作系统对其进行管理,进程结束后,需要操作系统对其占用的资源进行回收,并“销毁”这个进程,对进程的所有操作,都是要消耗计算机资源的,至少是时间资源

(2) 每个进程有一套独立的数据

如果在一台机器上同时运行两个qq账户,这时就有了两个不同的进程,这两个进程各自运行各自的,而且每一个进程随时会进入运行状态,又会从运行状态中退出,下一次有进入运行状态,这需要进程能够在暂时退出运行状态时,记住当前运行的所有详细状况,以便下一次能成功运行

每个进程都需要记载大量的数据,进程的管理不但要消耗计算机时间资源,还要消耗计算机内存资源

(3) 进程是计算机资源的竞争者

某次进程可能需要计算机的各种资源,如:输入,输出,文件资源,,磁盘资源等等

进程向操作系统申请这些资源,对于计算机资源的多样性和进程对资源申请的多样性,如果不加以严格的管理,可能会导致严重的后果

(4) 进程的管理与调度

在这里插入图片描述
对于上图,强调以下几点:
1,进程(线程)被创建后,不是立刻进入运行态,先进入就绪态
2,阻塞态的进程(线程)在唤醒前,没有资格竞争CPU
3,阻塞态的进程(线程)必须由其它进程(线程)唤醒
4,被唤醒的进程(线程)不是立刻进入运行态,而是进入就绪态

2 保护临界资源

(1) 原语

由高级程序设计语言编写的源程序的一条语句,编译成机器语言后可能对应多条语句,而在多道程序并行环境中,这些多条语句的执行可能随时被中断

但是在实际编程需求中,有些语句的执行是不希望被打断的,必须完全执行,为了满足这样的要求,计算机系统提出了“原语”

原语:一条或多条语句,对于它们的执行不会被中断

对于进程(线程)的创建,调度,阻塞,唤醒的操作实质上都是原语,作为原语,应该做到:代码尽可能少,尽可能不要出现长循环,尤其不能出现递归调用和I/O操作

(2) 临界资源

有些情况下,需要两个或多个进程之间的互相配合,共同完成某一编程任务,它们之间通过“共享数据”的方式建立联系,对于这种存在着关联关系的多个进程,如果不仔细处理其中的逻辑关系,就可能造成程序的失败

临界资源指的是进程中的某些代码段,在进程的代码中,与“共享数据”操作有关的代码往往被称为临界资源

(3) 锁

对于临界资源,通过加锁,阻止不该进入的进程

对于锁的的几点声明:
1,锁必须是所有相关进程共享的,即大家都知道这个锁的存在
2,多个相关进程检查/打开/关闭的应该是同一把锁
3,进程(线程)若是遇到锁,一定先查看锁的状态

若锁的状态是打开的,先关闭锁,再进入临界资源
若锁的状态是关闭的,则阻塞自己,将自己放入该锁的阻塞态队列

查看锁和关闭是一个原语,不会被打断,在Java中,对锁的查看,开锁和关锁操作,都是由JVM实现的

(4) 进程与线程

线程是轻量级的进程

线程是由进程创建的,但线程不再申请另外的计算机资源,即线程不需要像进程那样有庞大的资源表,也不需要像进程那样对资源进行严格的管理

线程所使用的资源都是进程申请的,多个线程的状态切换比进程更简单,更省时,线程也可以生成新的线程(子线程)

P2 创建多线程

1 继承Thread类

线程类:

package com.mec.thread;

public class MyThread extends java.lang.Thread {
    
    
	
	private String threadName;
	
	public MyThread(String threadName) {
    
    
		this.threadName = threadName;
	}
	
	//Thread类中必须覆盖run()方法
	//run()方法体中的内容是线程将执行的代码
	@Override
	public void run() {
    
    
		for(int i = 0; i < 100; i++) {
    
    
			System.out.println(this.threadName + ":" + i);
		}	
	}
	
}

测试类:

package com.mec.thread.test;

import com.mec.thread.MyThread;

public class Test {
    
    

	public static void main(String[] args) {
    
    
		
		MyThread thread1 = new MyThread("线程1");
		MyThread thread2 = new MyThread("线程2");
		MyThread thread3 = new MyThread("线程3");
		
		
		//start()用来创建一个新线程,将该创建的线程放入就绪态
		//这个线程等待JVM的线程调度器,被JVM调度到运行态方可执行
		thread1.start();
		thread2.start();
		thread3.start();	
	}

}

运行结果1:
在这里插入图片描述
运行结果2:
在这里插入图片描述
对比这两次运行结果发现各个线程运行的状况是不确定的,这与当前时刻操作系统的状态有关,每一个线程都是独立运行的

在向测试类中加一行输出语句:
在这里插入图片描述
运行结果1:
在这里插入图片描述
运行结果2:
在这里插入图片描述
对于输出“"main()函数所在主线程运行结束”这句话,绝大多数都是输出在第一行

上述的结果再一次说明了start()方法的作用是创建线程,而不是执行线程,start()创建一个线程后,将其放入就绪态,等待操作系统调度

2 实现Runnable接口

线程类:

package com.mec.thread;

public class MyThread2 implements Runnable {
    
    
	
	//count被static修饰,只有一份
	//在这个程序中,用两个线程来更改同一个count的值
	private static int count;
	private String threadName;
	
	public MyThread2(String threadName) {
    
    
		this.threadName = threadName;
	}	
	
	

	@Override
	public void run() {
    
    
		
		for(int i = 0; i < 100; i++) {
    
    
			
			MyThread2.count += 5;
			for(int j = 0; j < 10000; j++) {
    
    
			}
			MyThread2.count -= 4;		
			System.out.println(this.threadName + ":" + MyThread2.count);
		}
	}
	
}

测试类:

package com.mec.thread.test;

import com.mec.thread.MyThread2;

public class Test2 {
    
    

	public static void main(String[] args) {
    
    
				
		//通过Runnable接口创建的线程类没有start()方法,
		//必须先生成一个对象
		//再使用Thread类的方法,将先前的对象作为一个参数传入
		//才可以使用start()方法
		MyThread2 myThread1 = new MyThread2("线程1");
		MyThread2 myThread2 = new MyThread2("线程2");
		
		
		Thread thread1 = new Thread(myThread1);
		thread1.start();
		new Thread(myThread2).start();;

	}

}

运行结果1:
在这里插入图片描述
在这里插入图片描述
运行结果2:
在这里插入图片描述
在这里插入图片描述
上述线程类中run()方法中的线程体目的是让n个线程独立各自运行100次,其中对static修饰的count进行操作,理想情况下线程交替运行,那么count最后的结果是100n,且输出是有顺序的,从1到100n

但是这仅仅是理想情况下,真实运行的多线程不可能这么简单,考虑假设线程1开始第一次运行,对count+5后,此时count的值是5,进入循环时,循环一段时间后,线程1的时间片用尽,进入就绪态,线程2被调度到运行态,开始执行代码,对于此时的count,它直接+5,此时count就成了10,接下来如此往复,线程2也可能在时间片用完后被调入就绪态,这样对于共享的数据count就彻底乱套了

3 使用extends继承Thread类和实现Runnable接口的区别

虽然extends Thread简明扼要,但是更推荐使用实现Runnable接口的方式来创建线程类,其原因有:

1,extends只能单继承,而接口可以多实现
2,Runnable接口很干净,里面只有一个run()方法

P3 锁

对于上面count的输出混乱的结果不是我们想要的,想要count有序的输出数据,这时就需要给临界资源(涉及到count的代码段)加锁

根据锁的定义:
1,锁必须是所有相关进程共享的,即大家都知道这个锁的存在
2,多个相关进程检查/打开/关闭的应该是同一把锁
3,进程(线程)若是遇到锁,一定先查看锁的状态

1 synchronized (lock) {…}

推荐使用对象锁,给临界资源上锁:

package com.mec.thread;

public class MyThread2 implements Runnable {
    
    
	
	//count被static修饰,只有一份
	//在这个程序中,用两个线程来更改同一个count的值
	private static int count;
	private String threadName;
	
	//定义单独一份的对象锁
	private static Object lock;
	
	//初始化一个对象锁
	static {
    
    
		lock = new Object();
	}
	
	public MyThread2(String threadName) {
    
    
		this.threadName = threadName;
	}	
	
	

	@Override
	public void run() {
    
    
		for(int i = 0; i < 100; i++) {
    
    
			//将与共享数据有关的临界资源上锁
			synchronized (lock) {
    
    
				MyThread2.count += 5;
				for(int j = 0; j < 10000; j++) {
    
    
				}
				MyThread2.count -= 4;
				
				System.out.println(this.threadName + ":" + MyThread2.count);	
			}	
		}	
	}
	
	
}

运行结果:
在这里插入图片描述
在对共享数据的临界资源加锁后,假设一开始线程1运行,线程1遇到lock,先检查lock此刻的状态,此时lock是打开的,线程1先对lock加锁,接着运行临界资源中的代码,如果此时线程1被调度到了就绪态,线程2开始运行,线程2遇到lock,检查到现在lock的状态是关闭的,线程2自己阻塞自己,操作系统将线程2放入到lock的阻塞队列中,此时线程2只能等待被唤醒,它无法竞争CPU,接着线程1继续运行临界资源中的代码,直到遇到synchronized (lock) { 的右花括号,此时线程1顺利的执行完了临界资源中的代码,且不会被其它的线程打断,线程1将lock打开,并唤醒该锁上阻塞队列的所有线程,将它们调度到就绪态,等待运行,自此count的输出就会变得有顺序

2 Thread.sleep(x)

对于上述的测试结果,可以看到一个线程可能执行很多次才轮到另一个线程,因为在整个线程体中,只有for(int i = 0; i < 100; i++)中进行i < 100和i++的操作时,才可能会被其它的线程打断,其它的所有程序都被lock包住了,如果想更有顺序的轮换线程运行,可以:

@Override
	public void run() {
    
    
		
		for(int i = 0; i < 100; i++) {
    
    
			try {
    
    
				Thread.sleep(10);
			} catch (InterruptedException e) {
    
    
			}
			//将与共享数据有关的临界资源上锁
			synchronized (lock) {
    
    
				MyThread2.count += 5;
				for(int j = 0; j < 10000; j++) {
    
    
				}
				MyThread2.count -= 4;
				
				System.out.println(this.threadName + ":" + MyThread2.count);	
			}
			
		}
		
	}

sleep(10),会让该线程空等10ms,这段时间,如果该线程的时间片用尽,就会有别的线程从就绪态被调度到运行态执行程序:

在这里插入图片描述

P4 volatile关键字

1 计算机存储体系简介

计算机存储体系从外到内分为5层:
1,海量外存,存储空间最大,速度最慢
2,外存,存储空间大,速度较慢
3,内存,存储空间不是很大,速度较快
4,高速缓存,存储空间很小,速度很快
5,寄存器,存储空间最小,速度最快

我们所编写的程序,变量,数组的本质就是内存空间,对变量,数组元素的访问本质上就是对内存的访问

for(int i = 0; i < 10000; i++) {
    
    
	...
}

对于循环的条件和步长i,会在程序中频繁被访问,如果每次都要从内存中访问i进行判断,从内存中取出i,对i+1后,再将其写入内存,程序的执行效率会因为内存访问速度较慢,而变得低效

2 变量的寄存器优化

很多编译软件都会对“要频繁访问内存的变量”,进行寄存器优化

即对于这个变量,编译系统第一次从内存中访问它时,用一个寄存器保存它的值,之后对它的读和写都在寄存器中完成,由于寄存器的高速,整个程序的速度就会提升

但这意味着,如果存在两个线程,它们对同一变量进行操作,但该变量被编译系统寄存器优化后,两个线程表面上是对同一线程进行访问,但是却是对各自的寄存器中的值进行访问,即就算某个线程更改了“同一个变量”的值,其本质只是操作自己的寄存器的值,并没有影响到内存,从而使得两个线程并没有真正联系在一起

3 private volatile static int count

对于先前的count,虽然程序测试的结果表明他没有被寄存器优化,但如果将循环增大,可能会出现上述的问题,所以对与共享数据,一般都加上volatile,避免寄存器优化有实用意义的共享数据导致多线程没有联系在一起

但使用volatile也是有代价的,volatile拒绝寄存器优化,坚持从内存中读写,也必然会降低速度,对volatile的使用不能泛滥

P5 使用Java多线程完成生产者,消费者问题

1 生产者,消费者问题

考虑这样一个场景:两个线程,一个线程负责生产数据,一个线程负责消耗数据,如果生产者没有生产出数据,消费者自然没法消费,但如果生产者已经生产出一个数据了,而消费者尚未消耗该数据,那么生产者应该等待消费者消耗完该数据,再生产数据

其本质就是两个线程的同步问题,做到两个线程交替执行即可

2 wait()和notify()

wait()方法的本质是让执行这个方法的线程进入阻塞态

notify()方法用于唤醒处在阻塞态的相关线程

3 实现过程

对于生产者,需要设置一个表示“之前生产的数据是否已经被消耗”的标志

对于消费者,需要设置一个表示“是否有数据可供消费”的标志

对于生产者和消费者,需要设置一个共同的对象锁

需要设置一个共享的数据,即生产者生产的数据消费者消耗的数据,这两个是同一个

通过创建一个父类,由两个子类继承来完成:

package com.mec.thread;

public class ProducerCustomer {
    
    
	
	//是否有数据以供消费
	public static boolean hasValue = false;
	
	//是否以消费
	public static boolean isConsume = true;
	
	//对象锁
	public static Object lock = new Object();
	
	//共享数据
	public volatile static int data;

}

生产者类:

package com.mec.thread;

import java.util.Random;

public class Producer extends ProducerCustomer implements Runnable {
    
    
	
	private Random random;
	private String threadName;
	private Thread thisThread;
	
	public Producer() {
    
    
		random = new Random();
		threadName = "生产者";
		thisThread = new Thread(this,threadName);
	}
	
	
	public void startProducer(){
    
    
		thisThread.start();
		System.out.println("线程" + threadName + "建立");
	}


	@Override
	public void run() {
    
    
			
		while(true) {
    
    	
			synchronized (lock) {
    
    
				//如果已消耗,生产一个数据,将已消耗标志改为false
				//将是否有数据标志改为true,唤醒阻塞的消费者
				if(isConsume) {
    
    
					data = random.nextInt(1000);
					System.out.println(threadName + "生产了一个数据" + data);
					isConsume = false;
					hasValue = true;
					lock.notify();
				} else {
    
    
					//如果未消耗,阻塞自己,等待被唤醒
					try {
    
    
						lock.wait();
					} catch (InterruptedException e) {
    
    
						e.printStackTrace();
					}
				}
			}	
		}		
	}
	
	
	
}

消费者类:

package com.mec.thread;

public class Customer extends ProducerCustomer implements Runnable {
    
    
	
	
	private String threadName;
	private Thread thisThread;
	
	public Customer() {
    
    
		threadName = "消费者";
		thisThread = new Thread(this,threadName);
	}
	
	
	public void startCustomer(){
    
    
		thisThread.start();
		System.out.println("线程" + threadName + "建立");
	}

	@Override
	public void run() {
    
    
		
		while(true) {
    
    
			synchronized (lock) {
    
    
				//如果有数据未消耗,消耗该数据,将已消耗标志改为true
				//将是否有数据改为false,唤醒阻塞的生产者
				if(hasValue) {
    
    
					System.out.println(threadName + "消耗了一个数据" + data);
					isConsume = true;
					hasValue = false;
					lock.notify();					
				} else {
    
    
					//如果已经消耗,则阻塞自己,等待被唤醒
					try {
    
    
						lock.wait();
					} catch (InterruptedException e) {
    
    
						e.printStackTrace();
					}
				}
			}
		}
	}

	
}

测试类:

package com.mec.thread.test;

import com.mec.thread.Customer;
import com.mec.thread.Producer;

public class TestPC {
    
    

	public static void main(String[] args) {
    
    
		
		Producer producer = new Producer();
		Customer customer = new Customer();
		
		producer.startProducer();
		customer.startCustomer();

	}

}

运行结果:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_43541094/article/details/110358008
今日推荐