Java 多线程学习总结(一):基础知识

基本概念

这里先介绍下进程与线程,并发与并行的概念

进程与线程

进程:进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。每个进程都有独立的代码和数据空间(进程上下文),一个进程至少有一个线程,进程中的多个线程是共享进程的资源的。

线程:有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源就比较特殊,它是分派到线程的,因为真正要占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。

一个进程内的多个线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),如下图所示:
在这里插入图片描述
图中,程序计数器是一块很小的内存区域,用来记录线程当前要执行的指令地址,那么程序计数器为何要设计为线程私有的呢?前面说了线程是占用 CPU 执行的基本单位,而 CPU 一般是使用时间片轮转方式让线程轮询占用的,所以当前线程 CPU 时间片用完后,要让出 CPU,等下次轮到自己时候在执行,那么如何知道之前程序执行到哪里了?其实程序计数器就是为了记录该线程让出 CPU 时候的执行地址,待再次分配到时间片时候就可以从自己私有的计数器指定地址继续执行了。

另外每个线程有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其它线程是访问不了的,另外栈还用来存放线程的调用栈帧。

堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时候分配的,堆里面主要存放使用 new 操作创建的对象实例。

方法区则是用来存放进程中的代码片段的,是线程共享的。

线程和进程总结

  1. 进程是资源分配的最小单位,线程是程序执行的最小单位。

  2. 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,进程间的切换开销较大。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。

  3. 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。

  4. 多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

并行与并发

并行:同一时刻,计算机执行两个或多个任务(需要多核CPU) 。例如:有两个大人和两个小孩,一个大人喂一个小孩吃饭,这是并行。

并行任务执行过程:
在这里插入图片描述

并发:指两个任务都请求运行,而处理器只能按受一个任务,就把这两个任务安排轮流进行,由由于切换的非常快,对于外界调用者来说相当于同一时刻多个任务一起执行了,但其实是没有时间上的重叠的。例如:只有一个大人,要喂两个小孩吃饭,只能这个喂一口再那个喂一口,这是并发。

并发任务执行过程:
在这里插入图片描述
并发的和并行区别的关键:是否同时。并发你有处理多个任务的能力,但不是同时处理。 并行是你有同时处理多个任务的能力。

创建线程的方式

每当使用java命令执行一个带main方法的类时,就会启动JVM(应用程序),实际上就是在操作系统中启动一个JVM进程,JVM启动时,必然会创建以下5个线程:

  1. main 主线程:执行我们指定的启动类的main方法

  2. Reference Handler: 处理引用的线程

  3. Finalizer: 调用对象的finalize方法的线程,就是垃圾回收的线程

  4. Signal Dispatcher: 分发处理发送给JVM信号的线程

  5. Attach Listener: 负责接收外部的命令的线程

Java 中有三种线程创建方法,分别为继承 Thread 类并重写 run 方法、实现 Runnable 接口的run方法、使用Callable和Future创建线程。

(一)继承 Thread

Thread 类本身实现了 Runnable 接口,并且持有 run 方法,但 Thread 类的 run 方法主体是空的,Thread 类的 run 方法通常是由子类的 run 方法重写,创建过程:

  1. 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
  2. 创建Thread子类的实例,即创建了线程对象。
  3. 调用线程对象的start()方法来启动该线程。
public class MyThread extends Thread {
	int i = 5;
	
	public MyThread(String name) {
		super(name);
	}
	
	@Override
	public void run() {
		while (true) {
			i--;
			System.out.println(this.getName() + "中i的值:" + i);
			if (i <= 0) {
				break;
			}
		}
	}
	
	public static void main(String[] args) {
		MyThread thread1 = new MyThread("线程1");
		MyThread thread2 = new MyThread("线程2");
		thread1.start();
		thread2.start();
	}
}

如上代码 MyThread 类继承了 Thread 类,并重写了 run 方法,然后调用了线程的 start 方法启动了线程。

注意:当调用了 start 方法后线程并没有马上执行而是处于就绪状态,这个就绪状态是指该线程已经获取了除 CPU 资源外的其它资源,等获取 CPU 资源后才会真正处于运行状态。

程序输出:

线程1中i的值:4
线程2中i的值:4
线程1中i的值:3
线程2中i的值:3
线程1中i的值:2
线程1中i的值:1
线程2中i的值:2
线程1中i的值:0
线程2中i的值:1
线程2中i的值:0

从运行结果可以看出,线程1和线程2是交替切换执行的,是并发执行的。

另外,要明白启动线程的是 start() 方法而不是 run() 方法,如果用 run() 方法,那么他就是一个普通的方法执行了。我们将上面的程序改为调用run() 方法:

thread1.run();
thread2.run();

程序输出:

线程1中i的值:4
线程1中i的值:3
线程1中i的值:2
线程1中i的值:1
线程1中i的值:0
线程2中i的值:4
线程2中i的值:3
线程2中i的值:2
线程2中i的值:1
线程2中i的值:0

可以看到,等待线程1的run()方法执行完毕,才执行线程2的run() 方法,并不是多线程并发执行的。

使用Thread继承方式的好处是run()方法内获取当前线程直接使用 this 就可以,无须使用 Thread.currentThread() 方法,不好的地方是 Java 不支持多继承,如果继承了 Thread 类那么就不能再继承其它类,另外任务与代码没有分离,当多个线程执行一样的任务时候需要多份任务代码,而 Runable 则没有这个限制,下面看下实现 Runnable 接口的方式。

(二)实现 Runnable

  1. 定义Runnable接口的实现类,一样要重写run()方法,这个run()方法和Thread中的run()方法一样是线程的执行体。

  2. 创建Runnable实现类的实例,并用这个实例作为Thread的target来创建Thread对象,这个Thread对象才是真正的线程对象。

  3. 同样调用线程对象的start()方法来启动线程。

public class MyRunnable implements Runnable {
	int i = 5;
	
	@Override
	public void run() {
		while (true) {
			i--;
			System.out.println(Thread.currentThread().getName() + "中i的值:" + i);
			if (i <= 0) {
				break;
			}
		}
	}
	
	public static void main(String[] args) {
		MyRunnable myRunnable = new MyRunnable();
		Thread thread1 = new Thread(myRunnable, "线程1");
		Thread thread2 = new Thread(myRunnable, "线程2");
		thread1.start();
		thread2.start();
	}
}

程序输出:

线程2中i的值:3
线程1中i的值:3
线程2中i的值:2
线程1中i的值:1
线程2中i的值:0

从输出结果可以发现,线程1和线程2共享了一个myRunnable对象中的资源,另外 MyRunnable 可以继承其他类。但是上面两种方法都有一个缺点就是任务没有返回值,下面看最后一种是使用 FutureTask:

(三)使用Callable和Future

  1. 创建Callable接口的实现类,并实现call()方法,然后创建该实现类的实例(从java8开始可以直接使用Lambda表达式创建Callable对象)。

  2. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了Callable对象的call()方法的返回值

  3. 使用FutureTask对象作为Thread对象的target创建并启动线程(因为FutureTask实现了Runnable接口)

  4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

public class MyCall implements Callable<String> {
	int i = 5;
	
	@Override
    public String call() throws Exception {	
		return (Thread.currentThread().getName() + "中i的值:" + i);	 
    }

	public static void main(String[] args) throws InterruptedException {
		FutureTask<String> futureTask  = new FutureTask<>(new MyCall());
		new Thread(futureTask).start();
		try {
			String result = futureTask.get();
			System.out.println(result);
		} catch (ExecutionException e) {
			e.printStackTrace();
		}
	}
}

程序输出:

Thread-0中i的值:5

可以发现,在实现Callable接口中,此时不再是run()方法了,而是call()方法,此call()方法作为线程执行体,同时还具有返回值!在创建新的线程时,是通过FutureTask来包装MyCallable对象,同时作为了Thread对象的target。

三种方式对比

  1. 相比继承Thread类,线程通过实现Runnable或Callable接口的方式,还可以继承其他类。

  2. 实现Runnable或Callable接口的方式,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。

  3. 继承Thread的方式可以直接通过 this 调用当前线程,而实现Runnable或Callable接口的方式编程稍微复杂,必须调用Thread.currentThread()方法访问当前线程。

  4. 继承Thread或实现Runnable接口的方式没有返回值,实现Callable接口的方式有返回值。

注意:一般推荐采用实现接口的方式来创建多线程。

线程的生命周期

线程是一个动态执行的过程,具有生命周期,下图描述了线程周期中的各种状态。
在这里插入图片描述
1. 新建状态(new):
使用 new 关键字建立一个线程对象后,该线程对象就处于这种状态。此时它已经分配了必需的系统资源,并执行了初始化,保持这个状态直到程序 start() 这个线程。

2.就绪状态(Runnable):
当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,只要调度器把时间片分配给线程,线程就可以运行。

3.运行状态(Running):
当就绪状态的线程获取 CPU 资源后,就可以执行 run() 中的代码,此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

4.阻塞状态(Blocked):
线程因为某个条件,失去了所占用的CPU资源,该线程就从运行状态进入阻塞状态。此时,调度器将忽略线程,不会给线程分配任何CPU时间。

5.死亡状态(Dead):
线程运行完 run() 方法或者异常中断发生时,该线程就变为死亡状态。

进入阻塞状态原因:

  1. 调用线程的 sleep() 方法使任务进入休眠状态,此时,任务在指定的时间内不会运行。
  2. 调用wait() 方法使线程挂起,并会释放线程持有的锁,将线程放入等待池中。直到线程得到了 notify() 或 notify All() 消息,线程才会被唤醒进入就绪状态。
  3. 任务等待某个输入输出完成。
  4. 线程在获取某个对象的 synchronized 同步锁时失败,因为另一个线程已经获取了这个锁。

线程同步

首先谈下什么是共享资源,所谓共享资源是说该资源被多个线程共享,多个线程都可以去访问或者修改的资源。另外本文当讲到的共享对象就是共享资源。

在处理多线程问题时,我们必须注意这样一个问题:当两个或多个线程同时访问同一个变量(共享对象),并且一个线程需要修改这个变量。我们应对这样的问题作出如何处理?

Java语言提供了专门机制以解决这种冲突,有效避免了同一个数据对象被多个线程同时访问。Synchronized 作为其中最基础常用的一个,是一种同步锁,其同步机制:当一个线程使用这个方法修饰共享对象时,其他线程想使用这个方法时就必须等待,直到线程使用完该方法。

它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
  4. 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。

线程调度方法

1.sleep() (线程休眠)

调用线程的 Thread.sleep(long millis) 方法,使其转到阻塞状态。millis参数用于指定该线程休眠的时间,以毫秒为单位。它通常在 run() 方法内的循环中使用,当睡眠结束后,就转为就绪(Runnable)状态。但它并不释放对象锁,也就是说如果有synchronized同步快,其他线程仍然不能访问共享数据。

public class SleepTest implements Runnable {
	int i = 5;
	
	@Override 
	public void run() {
		while (i > 0) {
			i--;
			long startTime = System.currentTimeMillis();
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			System.out.println("休眠时间:" + (System.currentTimeMillis() - startTime) + " ms");
			System.out.println(Thread.currentThread().getName() + "中i的值:" + i);
		}
	}
	
	public static void main(String[] args) {
		SleepTest sTest = new SleepTest();
		new Thread(sTest).start();
	}
}

程序输出

休眠时间:1013 ms
Thread-0中i的值:4
休眠时间:1016 ms
Thread-0中i的值:3
休眠时间:1010 ms
Thread-0中i的值:2
休眠时间:1001 ms
Thread-0中i的值:1
休眠时间:1010 ms
Thread-0中i的值:0

2.wait() (线程等待)

Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。

当一个线程执行到 wait() 方法时,它就进入到一个和该对象相关的等待池中,同时失去(释放)了对象的机锁(暂时失去机锁,wait(long timeout)超时时间到后还需要返还对象锁);其他线程可以访问。

另外需要注意的是如果调用 wait() 方法的线程没有事先获取到该对象的监视器锁,则调用 wait() 方法时候调用线程会抛出 IllegalMonitorStateException 异常。

wait和sleep区别

面试经常会问道wait和sleep的区别,它们的不同点有:

  1. sleep() 和 yield() 是Thread类的方法;wait(),notify()和 notifyAll() 是Object的方法。
  2. sleep() 方法没有释放锁,而 wait() 方法释放了锁。
  3. wait() 与 notify()/notifyAll() 方法必须在同步代码块中使用,而 sleep() 可以在任何地方使用。

3.join() (等待线程终止)

在项目实践时候经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如你在看电视,突然有人上门收水电费,你得付完水电费后才能继续看电视。Thread 类中有个 join 方法就可以做这个事情,在 Thread 类里面提供的。

下面看一个简单的例子来介绍 join 的使用:

public class MyThread extends Thread {
	int i = 3;
	
	public MyThread(String name) {
		super(name);
	}
	
	@Override
	public void run() {
		while (true) {
			i--;
			System.out.println(this.getName() + "中i的值:" + i);
			if (i <= 0) {
				break;
			}
		}
	}
	
	public static void main(String[] args) {
		System.out.println("主线程" + Thread.currentThread().getName() + "开始执行");
		MyThread thread1 = new MyThread("线程1");
		MyThread thread2 = new MyThread("线程2");
		thread1.start();
		thread2.start();
		System.out.println("主线程" + Thread.currentThread().getName() + "执行完毕");
	}
}

程序输出:

主线程main开始执行
主线程main执行完毕
线程2中i的值:2
线程1中i的值:2
线程2中i的值:1
线程1中i的值:1
线程2中i的值:0
线程1中i的值:0

可以看到,主线程执行完毕后才开始执行线程1和线程2。此时,如果我们需要线程1和线程2执行完毕后,再执行完主线程,可以在 start() 方法后调用 join() 方法,如下:

public static void main(String[] args) throws InterruptedException {
		System.out.println("主线程" + Thread.currentThread().getName() + "开始执行");
		MyThread thread1 = new MyThread("线程1");
		MyThread thread2 = new MyThread("线程2");
		thread1.start();
		thread2.start();
		thread1.join();
		thread2.join();
		System.out.println("主线程" + Thread.currentThread().getName() + "执行完毕");
	}

程序输出:

主线程main开始执行
线程2中i的值:2
线程2中i的值:1
线程1中i的值:2
线程1中i的值:1
线程2中i的值:0
线程1中i的值:0
主线程main执行完毕

4.yield() (线程礼让)

Thread类提供了一种礼让方法,用 yield() 方法表示。它会使当前正在运行的线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。

也就是说放弃的时间不确定,有可能刚刚放弃,马上又获得CPU时间片。

class Thread1 extends Thread {
	int i = 3;
	@Override
	public void run() {
		while (i > 0) {
			//线程礼让
			Thread.yield();
			i--;
			System.out.println("线程1中i的值:" + i);		
		}	
	}
}

class Thread2 extends Thread {
	int i = 3;
	@Override
	public void run() {
		while (i > 0) {
			i--;
			System.out.println("线程2中i的值:" + i);
		}
	}
}

public class YieldTest {
	public static void main(String[] args) {
		new Thread1().start();
		new Thread2().start();
	}
}

程序输出:

线程1中i的值:2
线程1中i的值:1
线程1中i的值:0
线程2中i的值:2
线程2中i的值:1
线程2中i的值:0

从代码可以看出,线程1执行了yield() 方法放弃当前的CPU资源,让给线程2去占用CPU执行时间。但从结果可以看出,线程1刚刚放弃,马上又获得CPU时间片。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
提供的源码资源涵盖了Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运。同时,源码配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 适合毕业设计、课程设计作业。这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习实践。通过学习和运这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进课程实践、课外项目或毕业设计。通过分析和运源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。 所有源码均经过严格测试,可以直接运,可以放心下载使用。有任何使用问题欢迎随时与博主沟通,第一时间进解答!

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值