浅谈Java并发编程基础知识
作者:云谷
进程和线程
在并行程序中进程和线程是两个基本的运行单元,在Java并发编程中,并发主要核心在于线程
1. 进程
一个进程有其专属的运行环境,一个进程通常有一套完整、私有的运行时资源;尤其是每个进程都有其专属的内存空间。
通常情况下,进程等同于运行的程序或者应用,然而很多情况下用户看到的一个应用实际上可能是多个进程协作的。为了达到进程通信的目的,主要的操作系统都实现了Inter Process Communication(IPC)资源,例如pipe和sockets,IPC不仅能支持同一个系统中的进程通信,还能支持跨系统进程通信。
2. 线程
线程通常也被叫做轻量级进程,进程线程都提供执行环境,但是创建一个线程需要的资源更少,线程在进程中,每个进程至少有一条线程,线程共享进程的资源,包括内存空间和文件资源,这种机制会使得处理更高效但是也存在很多问题。
多线程运行是Java的一个主要特性,每个应用至少包含一个线程或者更多。从应用程序角度来讲,我们从一条叫做主线程的线程开始,主线程可以创建别的其他的线程。
线程生命周期
一个线程的生命周期包含了一下几种状态
1、新建状态
该状态线程已经被创建,但未进入运行状态,我们可以通过start()方法来调用线程使其进入可执行状态。
2、可执行状态/就绪状态
在该状态下,线程在排队等待任务调度器对其进行调度执行。
3、运行状态
在该状态下,线程获得了CPU的使用权并在CPU中运行,在这种状态下我们可以通过yield()方法来使得该线程让出时间片给自己或者其他线程执行,若让出了时间片,则进入就绪队列等待调度。
4、阻塞状态
在阻塞状态下,线程不可运行,并且被异除出等待队列,没有机会进行CPU执行,在以下情况出现时线程会进入阻塞状态
- 调用suspend()方法
- 调用sleep()方法
- 调用wait()方法
- 等待IO操作
线程可以从阻塞状态重回就绪状态等待调度,如IO操作完毕后。
5、终止状态
当线程执行完毕或被终止执行后便会进入终止状态,进入终止状态后线程将无法再被调度执行,彻底丧失被调度的机会。
线程对象
每一条线程都有一个关联的Thread对象,在并发编程中Java提供了两个基本策略来使用线程对象
- 直接控制线程的创建和管理,在需要创建异步任务时直接通过实例化Thread来创建和使用线程。
- 或者将抽象好的任务传递给一个任务执行器 executor
1. 定义和开始一条线程
在创建一个线程实例时需要提供在线程中执行的代码,有两种方式可以实现。
提供一个Runnable对象,Runnable接口定义了一个run方法,我们将要在线程中执行的方法放到run方法内部,再将Runnable对象传递给一个Thread构造器,代码如下。
public class ThreadObject { public static void main(String args[]) { new Thread(new HelloRunnable()).start(); } } // 实现Runnable接口 class HelloRunnable implements Runnable { @Override public void run() { System.out.println("Say hello to world!!!"); } }
继承Thread,Thread类自身实现了Runnable接口,但是其run方法什么都没做,由我们自己根据需求去扩展。
public class ThreadObject { public static void main(String args[]) { new HelloThread().start(); } } // 继承Thread,扩展run方法 class HelloThread extends Thread { public void run() { System.out.println("Say hello to world!!!"); } }
两种实现方式的选取根据业务场景和Java中单继承,多实现的特性来综合考量。
2. 利用Sleep暂停线程执行
sleep()方法会使线程进入阻塞队列,进入阻塞队列后,线程会将CPU时间片让给其他线程执行,sleep()有两个重载方法sleep(long millis)和sleep(long millis, int nanos)当到了指定的休眠时间后,线程将会重新进入就绪队列等待调度管理器进行调度
public static void main(String args[]) throws InterruptedException { for (int i = 0; i < 4; i++) { System.out.println("print number "+ i); // 将主线程暂停4秒后执行,4秒后重新获得调度执行的机会 Thread.sleep(4*1000); } }
3. 中断
当一个线程被中断后就代表这个线程再无法继续执行,将放弃所有在执行的任务,程序可以自己决定如何处理中断请求,但通常都是终止执行。
在Java中与中断相关的有Thread.interrupt()、Thread.isInterrupted()、Thread.interrupted()三个方法
Thread.interrupt()为设置中断的方法,该方法会将线程状态设置为确认中断状态,但程序并不会立马中断执行只是设置了状态,而Thread.isInterrupted()、Thread.interrupted()这两个方法可以用于捕获中断状态,区别在于Thread.interrupted()会重置中断状态。
4. Join
join方法允许一条线程等待另一条线程执行完毕,例如t是一条线程,若调用t.join()方法,则当前线程会等待t线程执行完毕后再执行。
线程同步 Synchronization
各线通信方式
- 共享对象的访问权限 如. A和B线程都有访问和操作某一个对象的权限
- 共享 对象的引用对象的访问权限 如. A和B线程都能访问C对象,C对象引用了D对象,则A和B能通过C访问D对象
这种通信方式使得线程通讯变得高效,但是也带来一些列的问题例如线程干扰和内存一致性错误。那些用于防止出现这些类型的错误出现的工具或者策略就叫做同步。
1. 线程干扰 Thread Interference
线程干扰是指多条线同时操作某一个引用对象时造成计算结果与预期不符,彼此之间相互干扰。如例
public class ThreadInterference{ public static void main(String args[]) throws InterruptedException { Counter ctr = new Counter(); // 累加线程 Thread incrementThread = new Thread(()->{ for(int i = 0; i<10000;i++) { ctr.increment(); } }); // 累减线程 Thread decrementThread = new Thread(()->{ for(int i = 0; i<10000;i++) { ctr.decrement(); } }); incrementThread.start(); decrementThread.start(); incrementThread.join(); decrementThread.join(); System.out.println(String.format("最终执行结果:%d", ctr.get())); } } class Counter{ private int count = 0; // 自增 public void increment() { ++this.count; } // 自减 public void decrement() { --this.count; } public int get() { return this.count; } }
理论上来讲,如果按照正常的思路理解,一个累加10000次一个累减10000次最终结果应该是0 ,但实际结果却是每次运行结果都不一致,产生这个结果的原因便是线程之间相互干扰。
我们可以把自增和自减操作拆解为以下几个步骤
- 获取count变量当前值
- 自增/自减 获取到的值
- 将结果保存回count变量
当多个线程同时对count进行操作时,便可能产生如下这一种状态
- 线程A : 获取count
- 线程B : 获取count
- 线程A: 自增,结果 为 1
- 线程B: 自减,结果为 -1
- 线程A: 将结果1 保存到count; 当前count = 1
- 线程B: 将结果-1 保存到count; 当前count = -1
当线程以上面所示的顺序执行时,线程B就会覆盖掉线程A的结果,当然这只是其中一种情况。
2. 内存一致性错误 Memory Consistency Errors
当不同的线程对应相同数据具有不一致的视图时,会发生内存一致性错误,详细信息参见 JVM内存模型
3. 同步方法
Java提供了两种同步的惯用方法:同步方法 synchronized methods 、同步语句 synchronized statements 。要使方法变成同步方法只需要在方法声明时加入synchronized关键字,如
class Counter{ private int count = 0; // 自增 public synchronized void increment() { ++this.count; } // 自减 public synchronized void decrement() { --this.count; } public synchronized int get() { return this.count; } }
声明为同步方法之后将会使得对象产生如下所述的影响
- 首先,不可以在同一对象上多次调用同步方法来交错执行,同步声明使得同一个时间只能有一条线程调用该对象的同步方法,当一条线程已经在调用同步方法时,其他线程会被阻塞block,无法调用该对象的所有同步方法。
- 其次,当同步方法调用结束时,会自动与同一对象的任何后续调用方法建立一个happens-before关联,这保证对对象状态的更改对所有线程可见。
4. 内部锁和同步
同步是围绕对象内部实体构建的,API规范通常将此类实体称之为监视器,内部锁有两个至关重要的作用
- 强制对对象状态的独占访问
- 建立至关重要的happens-before关系
每个对象都有与其关联的固有锁,通常,需要对对象的字段进行独占且一致的访问前需要获取对象的内部锁,然后再使用完成时释放内部锁,线程在获取后释放前拥有该对象的内部锁。只要线程拥有了内部锁其他任何线程都无法获取相同的锁,其他线程在尝试获取锁时将被阻塞。在线程释放内部锁时,该操作将会在该对象的任何后续操作间建立happens-before关系。
4.1 同步方法中的锁
当线程调用同步方法时,线程会自动获得该方法所属对象得内部锁,并且在方法返回时自动释放,即使返回是由未捕获异常导致。静态同步方法的锁不同于实例方法的锁,静态方法是围绕该类进行控制而非该类的某一个实例。
4.2 同步语句
另外一个提供同步的方法是同步代语句,与同步方法不同的是,同步语句必须指定一个对象来提供内部锁。 public class IntrinsicLock { private List<String> nameList = new LinkedList<String>(); private String lastName; private int nameCount; public void addName(String name) { // 当多条线程对同一个实例对象的addName()方法操作时将会是同步的,提供锁的对象为该实例对象本身 synchronized(this) { lastName = name; nameCount++; } nameList.add(name); } }
同步语句对细粒度同步提高并发性也很有用,比如我们需要对同一个对象的不同属性进行同步修改我们可以通过如下代码来提高细粒度同步控制下的并发。
public class IntrinsicLock { // 1. 该属性需要基于同步的修改 private String lastName; // 1. 该属性也需要基于同步的修改 private int count; // 该对象用于对lastName提供内部锁 private Object nameLock = new Object(); // 该对象用于对nameCount提供内部锁 private Object countLock = new Object(); public void addName(String name) { synchronized(nameLock) { lastName = name; } } public void increment() { synchronized(countLock) { count++; } } }
这样,对lastName的操作不会阻塞count属性的自增操作,因为他们分别使用了不同的对象来提供锁。若像上一个例子中使用this来提供锁的话,则在调用addName()方法时increment()也被阻塞,反之亦然,这样将会增加不必要的阻塞。
4.3 可重入同步
线程无法获取另外一个线程已经拥有的锁,但是线程可以多次获取它已经拥有的锁,允许线程多次获取同一锁可以实现可重入的同步,即同步方法或者同步代码块中又调用了由同一个对象提供锁的其他同步方法时,该锁可以多次被获取
public class IntrinsicLock { private int count; public void decrement(String name) { synchronized(this) { count--; // 调用其他由同一个对象提供锁的同步方法时,锁可以重复获取 // 但只能由当前有用锁的线程重复获取 increment(); } } public void increment() { synchronized(this) { count++; } } }
4.4 原子访问
在编程中,原子操作指的是指所有操作一行性完成,原子操作不可能执行一半,要么全都执行,要么都不执行。在原子操作完成之前,其修改都是不可见的。在Java中以下操作是原子性的。
- 读写大部分原始变量(除了long和double)
- 读写所有使用volatile声明的变量
原子操作的特性使得我们不必担心线程干扰带来的同步问题,但是原子操作依然会发生内存一致性错误。需要使用volatile声明变量以有效防止内存一致性错误,因为写volatile标记的变量时会与读取该变量的后续操作建立happens-before关系,所以改变使用volatile标记变量时对其他线程总是可见的。也就是它不仅可以观测最新的改变,也能观测到尚未使其改变的操作。
5. 死锁
死锁是描述一种两条或多条线程相互等待(阻塞)的场景,如下例子所示
public class DeadLock { static class Friend { String name; public Friend(String name) { super(); this.name = name; } public String getName() { return name; } public synchronized void call(Friend friend) { System.out.println(String.format("%s被%s呼叫...", name,friend.getName())); friend.callBack(this); } public synchronized void callBack(Friend friend) { System.out.println(String.format("%s呼叫%s...", friend.getName(),name)); } } public static void main(String args[]) { final Friend zhangSan = new Friend("张三"); final Friend liSi = new Friend("李四"); new Thread(new Runnable() { public void run() { zhangSan.call(liSi); } }).start(); new Thread(new Runnable() { public void run() { liSi.call(zhangSan); } }).start(); } }
如果张三呼叫李四的同时,李四呼叫张三,那么他们会永远等待对方,线程永远阻塞。
6. 饥饿和活锁
相对死锁而言,饥饿和活锁问题要少得多,但是也应注意。
6.1 饥饿
饥饿是一种描述线程无法定期访问共享资源,程序无法取得正常执行的一种场景,比如一个同步方法执行时间很长,但是多条线程争抢且频繁的执行,那么将会有大量线程无法在正常的情况下获得使用权,造成大量阻塞和积压,我们使用饥饿来描述这种并发场景。
6.2 活锁
活锁是一种描述线程在执行同步方法的过程中依赖其他外部资源,而该部分获取缓慢而无保障造成无法进一步执行的的场景,相对于死锁,活锁是有机会进一步执行的,只是执行过程缓慢,造成部分资源被 正在等待其他资源的线程占用。
7. 保护块/守护块
通常,线程会根据其需要来协调其操作。最常用的协调方式便是通过守护块的方式,用一个代码块来轮询一个一条件,只有到该条件满足时,程序才继续执行。要实现这个功能通常有几个要遵循的步骤,先给出一个并不是那么好的例子请勿在生产代码使用以下示例
public void guardedJoy() { // 这是一个简单的轮询守护块,但是极其消耗资源 // 请勿在生产环境中使用此类代码,这是一个不好的示例 while(!joy) {} System.out.println("Joy has been achieved!"); }
这个例子中,只有当别的线程讲joy变量设置为true时,程序才会继续往下执行,在理论上该方法确实能实现守护的功能,利用简单的轮询,一直等待条件满足后,才继续往下执行,这是这种轮询方式是极其消耗资源的,因为轮询会一直占用CPU资源。别的线程便无法获得CPU进行处理。
一个更为有效的守护方式是调用Object.wait方法来暂停线程执行,暂停后线程会被阻塞,让出CPU时间片给其他线程使用,直到其他线程发出一个某些条件已经满足的通知事件后,该线程会被唤醒重新执行,即使其他线程完成的条件并非它等的哪一个条件。更改上面的代码
public synchronized void guardedJoy() { // 正确的例子,该守护快每次被其他线程唤醒之后只会轮询一次, while(!joy) { try{ wait(); }catch(Exception e) {} } System.out.println("Joy has been achieved!"); }
为什么这个版本的守护块需要同步的?假设d是一个我们调用wait方法的对象,当线程调用d.wait()方法时线程必须拥有对象d的内部锁,否则将会抛出异常。在一个同步方法内部调用wait()方法是一个简单的获取对象内部锁的方式。当wait()方法被调用后,当前线程会释放内部锁并暂停执行,在将来的某一刻,其他线程将会获得d的内部锁,并调用d.notifyAll()方法,来唤醒由对象d.wait()方法暂停执行的线程。
public synchronized notifyJoy() { joy = true; // 唤醒所有被wait()方法暂停的线程 notifyAll(); }
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。