Java(多线程篇)

一、线程基础

线程是现代软件系统中十分重要的概念,我们从线程的概念,线程的调度,线程安全,用户线程与内核线程之前的映射关系来了解。

 

什么是线程?

线程(Thread),有时被称为轻量级进程,是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆等)及其一些进程资源(如打开文件和信号)。

一般什么时候使用多线程呢?

  • 某个操作陷入长时间的等待,等待的线程会陷入睡眠状态,无法继续执行。如网络响应。
  • 某个操作会消耗大量的时间,单线程下程序和用户之间交互中断。如大量的计算。
  • 程序要求并发操作。如多端下载
  • 多CPU多核计算机。
  • 多进程应用。多线程在数据共享上应用效率高。

线程的访问权限。

线程也有着自己的私有存储空间。

  • 栈(尽管并非无法被其他线程访问,一般是认为是线程私有的数据)
  • 线程局部存储(Thread Local Storage,TLS)某些操作系统单独提供的私有空间,但通常容量有限。

寄存器(包括PC寄存器),它是执行流基本的数据。

程序员的角度来看数据在线程之间是否私有。

线程调度与优先级

其实线程真实的存在并发,是发生在多处理器计算机上的。当线程数量不大于处理器数量时,不同的线程运行在不同的处理器上,彼此互不相关。但是当线程数量数量大于处理器时,线程并发会发生一些阻碍,因为总至少有一个处理器运行多个线程。单核处理器对应多线程只不过是一种模拟的假象。操作系统会让这些多线程程序轮流执行,每次执行一小段时间(通常是几十毫秒到几百毫秒),这样看起来像是在“并发”。这样一个不断在处理器上切换不同的线程的行为称之为线程的调度。线程调度至少有三种状态:

  • 运行:此时正在执行
  • 就绪:可执行,但是CPU被占用
  • 等待:等待某一件事(IO事件)无法被执行。

主流的调度方式各不相同,但都带有优先级调度轮换法的痕迹。所谓轮换法,就是线程轮流执行一小段时间(这个时间就是时间片,当时间片用完,线程也就由运行状态进入就绪状态)。这决定了线程之间的交错执行的特点。而优先级决定了线程按照什么顺序轮流执行。线程具有自己的优先级。优先级越高就越先被执行。

线程的优先级在主流的liunx和Windows上不仅可以手动设置,而且操作系统也可以自己调整,以至于有更高的效率。一般频繁进入等待状态的线程(通常称为IO密集型线程)比频繁进行大量计算,以至于每次把时间片用完的线程(CPU密集型线程)容易得到优先级的提升。因为频繁进入等待的线程通常占用时间少(CPU也喜欢先捏软柿子)。

在优先级的调度下,存在一种饿死现象,一个线程被饿死,是说它优先级比较低,在执行之前,总有优先级较高的线程试图执行,因此自己始终无法执行。调度系统为了防止这种现象的发生,会逐步提升这些线程的优先级,直到能被执行到为止。

可抢占线程不可抢占线程

当线程用完时间片之后会被剥夺继续执行的权力,而进入就绪状态,这个过程叫抢占。早起的系统也有不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他线程执行。不可抢占线程中,线程主动放弃执行无非两种情况:试图等待某个事件(io);主动放弃时间片。在不可抢占线程执行有一个特点,就是线程调度只发生在线程主动放弃执行或线程等待某事件的时候。这样可以避免因为抢占线程调度时机不确定而产生的问题(线程安全)。即便如此,非抢占线程在今日也很少见。

 

二、线程安全

多线程处于一个可变的环境中,可访问的全局变量和堆数据随时都可以被其他的线程改变,因此怎么保证多线程数据安全是一个非常重要的问题。

 

竞争与原子操作

先举一个例子:

package ThreadTest;

public class ThreadTest extends Thread {
	
	static int i = 5;
	
	public static void main(String[] args) {
		
		new ThreadTest().start();
		new ThreadTest().start();
	}
	
	@Override
	public void run() {
		for(;;) {
	           if(i < 0 ) {
			break;
			}
			i -- ;
			System.out.println("i = " + i);
		
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		
	}
	
}

执行结果:

i = 3
i = 3
i = 1
i = 1
i = 0
 

在许多结构体系上,i--的实现方法如下:

  • 读取 i 到某个寄存器X
  • X --
  • 将X的内容存储回 i。

由于两个线程是并发执行,因此可能会出现,当线程A自减一次(4)还没写回到内存,但是有可能线程B已经执行过两次自减了(3)且写回到内存了,当线程A写回时已经覆盖了原先数据。为了避免这种情况发生,Java提供了一种同步锁(synchronized)来保证数据安全。我们把单指令的操作称为原子的(Atomic),因为无论如何,单指令的执行是不会被打断的。

加上synchronized之后上面的例子:

package ThreadTest;

public class ThreadTest extends Thread {

	static int i = 5;

	static final String lock = "lock";
	

	public static void main(String[] args) {

		new ThreadTest().start();
		new ThreadTest().start();
	}

	@Override
	public void run() {
		for (;;) {
			if (i <= 0) {
				break;
			}
			synchronized (lock) {
				i--;
				System.out.println("i = " + i);
			}
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}

	}

}

输出:

i = 4
i = 3
i = 2
i = 1
i = 0

 

同步与锁

为了保证多线程同时读写同一数据而产生不可预料的后果。我们需要将各个线程对同一数据的访问同步。所谓的同步,就是一个线程访问数据未结束的时候,其他线程不对同一个数据进行访问。如此对数据访问就被原子化了。 

同步常见的就是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或者资源之前都会尝试获取锁,并在访问结束的时候释放锁。在锁已经被占用的时候尝试获取锁,线程会等待,知道锁重新可得。

二元信号量:是最简单的一种锁,它只有两种状态,占用与非占用。它适合只能唯一一个线程独占访问资源。

互斥量:与二元信号量相似,但是不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说同一信号量可以被系统中一个线程获取之后由另一个线程释放。而互斥量要求哪个线程获取就有哪个线程释放,其他线程释放是无效的。

临界区:是比互斥量更加严格的手段。术语中,把临界区的锁获取叫做进入临界区,释放锁叫做离开临界区。在系统中二元信号量和互斥量对进程是可见的,也就是说一个进程创建了一个互斥量或信号量。另一个进程去获取是合法的。然而,临界区的作用范围仅限于本进程。除此之外。临界区和互斥量有相同的性质。

读写锁:致力于一种更加特定的场合的同步。对于一段数据,多个线程读是没问题,问题在于写,可能会出现覆盖。但是为了满足大量的读,只有少量的写的环境。读写锁有两种获取方式:共享锁独占锁。如果锁处于共享状态,那么任意线程以共享的方式获取,都会成功。要是以独占的方式获取,那么就必须等待所有持有该锁的线程释放共享锁。相应的处于独占状态的锁将阻止任何线程来获取。

条件变量:作为一种同步手段,作用类似于一个栅栏。使用条件变量可以让许多线程一起等待某个事件发生,当事件发生(条件变量被唤醒),所有线程可以一起恢复执行。

上面这些概念都是C++里面的,虽然Java锁这一块被封装起来了,但锁的思想其实还是有些相似的。一般,Java会使用synchronized关键字实现一系列的锁。具体可参考:Java中各种锁的详细介绍

说起数据安全,我想大家还想到的一个就是volatile关键字。再说之前再来举一个例子。

x = 0
Thread1Thread2
locak();locak();
x++x++
unlock();unlock();

 

 

 

 

 

 

由于有lock和unlock的保护,x++的行为不会被并发破坏,那么x的值必然是2了。然而。如果编译器为了提高x的访问速度,把x放到某个寄存器里(各个线程之间寄存器是互相独立的),则线程可能出现如下情况:

  • Thread1:读取x的值到某一个寄存器R[1](R[1] = 0)
  • Thread1:R[1]++(可能之后还会访问,所以暂时不讲R[1]写回x)
  • Thread2:读取x的值到某一个寄存器R[2](R[2] = 0)
  • Thread2:R[2]++(R[2]=1)
  • Thread2:将R[2]写回至x(x=1)
  • Thread1:(很久之后)将R[1]写回至x(x=1)

可见这样的情况下即使正确的加锁,也是不能保证多线程的安全。这时就可以使用volatiles关键字了,volatile基本上做到两件事:

  • 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
  • 阻止编译器调整操作volatile变量的指令顺序。

可见volatile可以很好的解决这一问题,但是volatile也有不能解决的,那就是无法阻止CPU动态的调度换序。在C++中,或者Java中new一个对象一般会分为三个步骤,1)分配内存空间,2)调用构造函数,3)将内存地址赋值给实例。这个步骤中2,3循序是完全可以颠倒的。有一个典例就是单例模式:

package Singleton;

public class Singleton {
	static final Object lock = new Object();
	private static Singleton singleton = null;
	public static Singleton getSingleton() {
		if (singleton == null) {
			synchronized (lock) {
				if (singleton == null) {
					singleton = new Singleton();
				}
			}
		}
		return singleton;
	}
}

在C++中,知道是可以申明一个指针的。假如按照C++中吧 singleton申明成一个指针,那么抛开逻辑,乍一看代码是没问题的,当函数返回时,singleton总是指向一个有效的对象。假如出现这样的情况:singleton的值已经不是空了,但任然没有构造完毕,这个时候如果出现另一个线程对getSingleton()调用,此时singleton==null 返回false,所以这个调用会直接返回尚未构造完全的对象地址提供用户使用。这样会不会出现问题呢?

在java中,singleton也不过是申明的一个引用。当new Singleton()过程中,会出现分配内存,singleton会指向该内存地址。那么这两个步骤是不是也会被打乱呢?也会出现像C++这样的情况呢?

可以看见CPU乱序执行能力让我们保证多线程安全变得异常困难,遗憾的是现在并不存在可移植的阻止换序的方法,通常情况下会调用CPU提供的一种指令--barrier,该指令会阻止CPU在该指令之前的指令交换到该指令之后。可以想象成barrier指令就是一个拦水坝,防止程序“穿透”这个大坝。

 

三、线程内部情况

三种线程模型

线程的并发执行是由多个处理器或操作系统调度来实现的。实际情况可能更为复杂一些;大多数操作系统是可以通过内核线程的,然而用户实际是使用的线程并不是内核线程,而是存在于用户态的用户线程。用户线程并不是在操作系统中对应同等量的内核线程。例如某些轻量级的线程库,对用户来说如果有三个线程同时执行,对内核来说很可能只有一个线程。

一对一线程

一个用户线程就唯一对应一个内核使用的线程(反过来不一定,一个内核线程在用户态不一定有对应的线程存在)。优点是线程之间是正在的并发。一个线程阻塞不会影响到其他线程。再多处理器上有更好的表现。缺点是,由于许多操作系统限制了内核数量,因此一对一线程会让用户线程数量收到了限制;其次许多操作系统内核调度时,上下文切换的开销大,导致用户线程执行效率底下。

多对一模型

多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换有用户态的代码来进行,因此相对一对多模型,多对一模型线程切换要快的多。但是它最大的一个问题就是,一个用户线程阻塞,那么所有的线程就无法执行。再多处理器上处理器增多对多对一模型线程性能没有明显的帮助。好处在于高效的上下文切换和无限制的线程数量。

多对多模型

多对多模型结合了一对一模型个多对一模型,将多个用户线程映射到少数但不止一个内核线程上。这样就综合了以上两个模型的优点。一个用户线程阻塞不会影响到其他用户线程,数量上也没有什么限制,在多处理器上,多对多模型也得到了一定性能的提升。但是幅度没有一对一的明显。

四、小结

以上都是从操作系统的角度,或者说从低层来看线程的是怎么分配的,怎么出现数据安全的问题,已经怎么同步来保证数据安全的。因此一部分是借鉴了C系语言里面的概念。在Java帝国,多线程也是比较复杂的,这里拓展一下,简单的温故温故。

五、拓展

至于jJava线程的创建,启动已经生命周期大家都耳熟能详了。简单说一下线程的同步,以及线程通信,和线程池。

线程同步

java关键词synchronized,是常用的的同步手段它可以修饰代码块,修饰方法。那么任何线程进入同步代码块。同步方法之前,必须现获取对同步监视器(某一对象,说白了就是一把锁)的锁定。何时会释放对同步监视器的锁定呢?程序无法显示释放对同步监视器的锁定,在以下情况下会释放:

  • 当前线程同步方法、同步代码块执行结束,或者出现异常结束了,当前线程释放同步监视器。
  • 当前线程执行同步代码块或同步方法,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器

以下请情况是不会释放同步监视器:

  • 线程执行同步代码块或者同步方法,程序程序出现Thread.sleep()、Thread.yield()方法来暂停当前线程执行,当前线程不会释放同步监视器
  • 线程执行同步代码块时,其他线程调用该线程的suspend()方法将线程挂起,该线程是不会释放同不监视器。当然程序应该尽量避免使用suspend()和resume()方法来控制线程。

Java1.5开始,Java提供了一种更强大的同步机制——显示定义同步锁对象来实现同步。在Java1.8有新增新型的StampedLock类,那么常用的是ReentrantLock(可重入锁)。该Lock对象可以显式的加锁、释放锁。如:

package Lock;

import java.util.concurrent.locks.ReentrantLock;

public class LockTest {
	
	private final ReentrantLock lock = new ReentrantLock();
	
	public void m() {
		lock.lock();
		try {
			
			//需要保护的安全的代码
			//....method body
			
		} finally {
			lock.unlock();
		}
		
	}
}

使用ReentrantLock 对象来进行同步,加锁和释放锁出现在不同的作用范围。通常建议使用finally块来确保在必要时释放锁。

同步方法或同步代码块使用与竞争资源相关、隐式的同步监视器,且强制要求加锁和释放锁要出现同一块结构中。而且当获取多个锁时。它们必须以相反的方向释放,且必须要在所有锁被获取时相同的范围内释放。虽然这样很方便且可以避免一些涉及到锁的一些常见错误。但是,Lock 提供了同步方法或同步代码块没有的功能。如:tryLock()方法,以及试图获取可中断锁的lockInterruptibly()方法,还有获取超时失效锁tryLock(long,TimeUtil)方法。

ReentrantLock 具有可重入性,一个线程可以对已经被ReentrantLock 锁再次加锁,ReentrantLock 对象会维持一个计数器来追踪lock()方法嵌套的使用,线程每次调用lock()加锁后,必须显示调用unLock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。

死锁:当两个线程互相等待对方释放同步监视器资源时,就会触发死锁,java虚拟机没有监测,也没有采取任何措施来处理死锁情况,多线程编程应该避免死锁。一旦出现死锁,整个程序不会发生异常,也没有任何提示,只是所有的线程处于阻塞状态,无法继续。

package Lock;

public class DeadLock {

	static final Object lock1 = new Object();
	static final Object lock2 = new Object();

	public static void main(String[] args) {
		new Lock1().start();
		new Lock2().start();
	}

}

class Lock1 extends Thread {

	@Override
	public void run() {
		try {
			System.out.println("Lock1 runing..........");
			for (;;) {
				synchronized (DeadLock.lock1) {
					System.out.println("Lock1 lock Object1");
					Thread.sleep(100);
					synchronized (DeadLock.lock2) {
						System.out.println("Lock2 lock Object2");
					}
				}

			}
		} catch (Exception e) {
		}

	}

}

class Lock2 extends Thread {
	@Override
	public void run() {
		try {
			System.out.println("Lock2 runing..........");
			for (;;) {
				synchronized (DeadLock.lock2) {
					System.out.println("Lock2 lock Object2");
					Thread.sleep(100);
					synchronized (DeadLock.lock1) {
						System.out.println("Lock1 lock Object1");
					}
				}
			}
		} catch (Exception e) {
		}

	}
}

输出:

Lock1 runing..........
Lock1 lock Object1
Lock2 runing..........
Lock2 lock Object2
 

线程一直处于阻塞状态。由于线程的suspend()方法也很容易导致死锁,故Java不在推荐使用该方法来暂停线程。

线程通信

传统的实现通信,可以借助Object类提供的wait()、notify()和notifyAll()三个方法,这三个方法并不属于Thread类,而是Object。这三个方法必须由同步监视器来调用,可分为一下两种情况。

  • 对于使用synchronized修饰的同步方法,因为默认使用实例(this)就是同步监视器所以可以在同步方法中直接调用这三个方法。
  • 对于使用synchronized修饰代码块,同步监视器是synchronized括号内的对象,所以必须使用该对象调用。

这里也可以明白为什么wait()、notify()和notifyAll()是属于Object类而不是Thread类了。关于这三个方法理解如下

wait():导致当前线程等待,直到其他线程调用该同步监视器的notify()和notifyAll()方法来唤醒该线程。该方法可以传入时间参数,表示等待该时间之后自动苏醒。

notify():唤醒该监视器上等待的单个线程。如果所有的线程都在该监视器上等待,则唤醒其中一个线程。选择是任意的。

notifyAll():唤醒该监视器上所有的等待线程。

当然假如程序不使用synchronized关键字来同步,而是直接使用Lock对象来保持同步。Java提供了一个Condition对象从而替代了监视器的功能。具体方法就不一一介绍了。

 

使用阻塞队列(BlockingQueue)控制线程通信

BlockingQueue有这样的一个特征;当生成线程试图放入元素时,若队列已满,则该线程被阻塞。当消费线程试图取出元素时,如果队列已空,则该线程处于阻塞状态。当然Java1.8新增了Java.util.Concurrent,提供了很多类似的集合。比较常用的有,ConcurrentHashMap,ConcurrentLinkedQueue等。

Java使用ThreadGroup来表示线程组,他可以对一批线程进行分类管理,Java允许直接对线程组进制控制。用户创建的所有线程都可以指定线程组,如果没有显示指定线程属于哪个线程组,则该线程属于默认线程组。在默认的情况下子线程和创建它的父线程属于同一个线程组。关于它的构造方法有:ThreadGroup(String name),ThreadGroup(ThreadGroup parent, String name)。

线程池

系统启用一个新线程的成本是比较高的,因为它涉及到与操作系统交互。如当程序需要大量创建生存期很短的线程时,应该考虑使用线程池。比较常见的例子就是数据库连接池。数据库的建立及关闭是极消耗资源的操作,在多层结构的应用环境中,这种资源的消耗对系统的影响尤为明显。因此数据库连接池解决方案是:当应用程序启动时,系统主动建立足够的数据库连接,并将这些连接组成一个连接池。每次应用请求数据库连接时,无须重新打开连接,而是从连接池中取出已有的连接使用,使用之后也无需关闭连接,而是直接将连接归还给连接池。Java提供了通过静态工厂来创建不同的类型的连接池:

  • newCachedThreadPool():创建一个具有缓存功能的线程池,系统更具需要创建线程,这些线程将缓存在线程池中。
  • newFixedThreadPool(int nThreads):创建一个可重用、具有固定线程数的线程池。
  • newSingleThreadExecutor():创建一个单线程的线程池。内部实现了newFixedThreadPool(1)。
  • newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize是池中保存的线程数,即使线程是空闲的也被保存在线程池内。
  • newSingleThreadScheduleExector():创建只有一个线程的线程池,它可以在指定延迟后执行线程任务。
  • ExecutorServicenewWorkStealingPool(int parallelism):创建持有足够的线程池来支持给定的并行级别,该方法还会使用多个队列来减少竞争。
  • ExecutorServicenewWorkStealingPool():该方法是前一个方法的简化,如果当前及其是4个CPU,则目标级别是4,也就是前一个方法参数传入4。

用完一个线程池后,应该调用线程池的shutdown()方法,该方法将启动线程池的关闭序列,调用shutdown方法后的线程不会在接收新的任务,但会将以前提交的任务执行完成。当池中所有线程都执行完后,池中所有池线程都会死亡。也可以调用showdownNow()方法,该方法会试图停止所有正在执行的活动任务,暂停处理等待的任务,并返回等待执行任务的列表。

使用线程来执行线程任务步骤如下:

  • 调用Executors类的静态工厂方法创建一个ExecutorService对象,该对象代表一个线程池。
  • 创建Runable实现类或Callable实现实例,作为线程执行任务。
  • 调用线程池的submit()来提交Runable或Callable实现实例
  • 当不想提交任何任务时,调用shutdown()方法,关闭线程。

Java1.8之后增加了一种特殊的线程池ForkJoinPool。为了充分利用CPU的多核能力,计算机软件系统应该充分“挖掘”每个CPU的计算机能力,决不能让某一个CPU处于空闲状态。因此可以考虑把一个任务拆分成多个小任务,把小任务放到多核处理器上并行执行,然后再把多个小任务的结果合并总的计算结果。ForkJoinPool是ExecutorService的实现类,是一种特殊的线程池。

线程相关ThreadLocal类

ThreadLocal是线程局部的变量,怎么理解呢?就是每一个使用该变量的线程都提供来了一个变量值的副本,使每一个线程都可以独立改变自己的副本,而不会和其他线程副本发生冲突。从线程的角度来看,就好像每个线程都完全拥有该变量一样。ThreadLocal也提供了泛型的支持,即:ThreadLocal<T>。

ThreadLocal提供了三个public方法:

  • T get():返回此线程局部变量中当前的线程的副本变量。
  • void remove():删除此线程局部变量中当前的线程副本的值
  • void set(T value):设置此线程局部变量中当前的线程副本的值

ThreadLocal和其他同步机制一样,都是为了解决多线程对同一变量的访问冲突。在普通的同步机制中,是通过加锁来实现多线程对同一变量的安全访问。而ThreadLocal从另一个角度,就是将并发访问资源复制多份,每个线程拥有一份资源,从而也就没有必要对该变量进行同步了。ThreadLocal提供了线程的安全共享对象,可以把不安全的整个变量封装进ThreadLocal,或者把该对象与线程相关状态使用ThreadLocal保存。

值得注意的是ThreadLocal并不能代替同步机制,通常建议:如果如果多个线程之间需要共享资源,从而达到 线程之间的通信,就使用同步机制。如果仅仅需要隔离多个线程之间的共享冲突,则可以使用ThreadLocal。

线程不安全集合与安全集合

不安全集合:ArrayList,LinkedList,HashSet,TreeSet,HashMap,TreeMap。可使用Collections提供的静态方法来包装从而转为线程安全集合。

安全集合:(以Concurrent开头的集合类)ConcurrentHashMap,ConcurrentLinkedQueue等。

 

思考:

结合ThreadLocal的思想,线程之间的通信,共享资源也可以不使用同步机制了。假如一个线程线程想修改另一个线程的数据,可以把要修改的数据局串行化发送给另一个线程,让其自己修改,修改完后再一次串行化,回调给请求线程。

比较经典的应用就是:Netty内部的线程通信,还有就是RPC框架。

 

 

参考:《程序员自我修养——链接、装载与库》,《疯狂java讲义》,《Netty权威指南》

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值