《Java多线程编程实战指南》笔记(一)多线程编程基础

一、多线程基础知识

  进程是程序向操作系统申请资源(如内存空间和文件句柄)的基本单位。线程是进程中可独立执行的最小单位。
  一个进程可以包含多个线程。同一个进程中所有线程共享该进程的资源,如内存空间、文件句柄等。线程所要完成的计算就被称为任务。特定的线程总是在执行特定的任务。
  函数式编程中的函数是基本抽象单位,面向对象编程中的类是基本抽象单位,相应地,多线程编程就是以线程为基本抽象单位的一种编程范式。当然,多线程编程和面向对象编程是兼容的,事实上Java平台中的一个线程就是一个对象。

1.1 线程的创建、启动与运行

  在Java中创建一个线程就是创建一个Thread类(或其子类)的实例。线程的任务处理逻辑可以在Thread类的run实例方法中直接实现或通过该方法进行调用,因此run方法相当于线程的任务处理逻辑的入口方法,它由虚拟机在运行相应线程时直接调用,而不是由相应代码进行调用。
  运行一个线程实际上就是让Java虚拟机执行该线程的run方法,从而使相应线程的任务处理逻辑代码得以执行。因此,首先要启动线程,Thread类的start方法的作用是启动相应的线程。启动一个线程的实质是请求Java虚拟机运行相应的线程,而这个线程具体何时运行是由线程调度器决定的。因此,start方法调用结束并不意味着相应线程已经开始运行,这个线程可能稍后才能运行,甚至也可能永远不会运行。
  Thread的两个常用构造器是Thread()和Thread(Runnable target),也就是说,线程的创建方式有两种:

  • 1、Thread类子类创建方式,示例代码如下:
/*Thread类子类*/
public class WelcomeThread extends Thread{
	  // 在该方法中实现线程的任务处理逻辑
	  @Override
	  public void run() {
	    System.out.printf("2.Welcome! I'm %s.%n", Thread.currentThread().getName());
	  }
}
/*测试类*/
public class ThreadTest1 {
	  public static void main(String[] args) {
		    // 创建线程
		    Thread welcomeThread = new WelcomeThread();
		    // 启动线程
		    welcomeThread.start();
		    // 输出“当前线程”的线程名称
		    System.out.printf("1.Welcome! I'm %s.%n", Thread.currentThread().getName());
	  }
}

  测试结果:

1.Welcome! I’m main.
2.Welcome! I’m Thread-0.

  • 2、Runnable接口创建方式,示例代码如下:
/*实现Runnable接口*/
public class WelcomeTask implements Runnable{
	  // 在该方法中实现线程的任务处理逻辑
	  public void run() {
	    // 输出“当前线程”的线程名称
	    System.out.printf("2.Welcome! I'm %s.%n", Thread.currentThread().getName());
	  }
}
/*测试类*/
public class ThreadTest2 {
	  public static void main(String[] args) {
		    // 创建线程
		    Thread welcomeThread = new Thread(new WelcomeTask());
		    // 启动线程
		    welcomeThread.start();
		    // 输出“当前线程”的线程名称
		    System.out.printf("1.Welcome! I'm %s.%n", Thread.currentThread().getName());
	}
}

  测试结果:

1.Welcome! I’m main.
2.Welcome! I’m Thread-0.

  当线程的run方法执行(由Java虚拟机调用)结束,相应的线程的运行也就结束了。当然,run方法执行结束也包括正常结束(run方法返回)以及代码中抛出异常的终止。运行结束的线程所占用的资源(如内存空间)会被Java虚拟机回收。
  线程每次只能使用一次,即通过调用start方法来使用,多次调用start方法会抛出IllegalThreadStateException。
  Java中的任何一段代码总是由确定的线程负责执行的,这个线程相应地被称为这段代码的执行线程,同一段代码可以被多个线程执行。任何一段代码都可以通过调用Thread.currentThread()来获取这段代码的执行线程,这个线程被称为当前线程。
  线程中run方法是由虚拟机直接调用的,即便如此,开发者依然可以执行调用run方法,但不推荐。因为这样的话线程的run方法其实是运行在当前线程(即run方法的调用方代码的执行线程),而不是运行在自身线程中。

1.2 Runnable接口

  Runnable接口中只定义了一个run方法,其实可以看作对执行任务的抽象。Thread其实也是实现Runnable接口的,Thread类中的run方法实现代码如下:

	public void run(){
		if (target != null){
			target.run();
		}
	}

  两种创建线程方式的区别:

  • 1、面向对象编程角度
      通过创建Thread子类的方式是一种基于继承的方式,通过以Runnable接口实例为构造参数直接通过new创建Thread实例是一种基于组合的方式。由于组合相对继承而言,代码的耦合性更低,也更灵活,一般优先使用组合的方式。
  • 2、对象共享角度
      以Runnable方式创建线程的方式意味着多个线程可以共享一个Runnable实例。
  • 3、对象创建成本角度
      Java中的线程实例是一个“特殊”的Runnable实例,因为在创建它的时候,Java虚拟机会为其分配调用栈空间、内核线程等资源。因此,创建一个线程实例比起创建一个普通的Runnable实例来说其成本要相对昂贵一些。

1.3 线程属性

  线程的属性包括线程的编号(ID)、名称(Name)、线程类别(Daemon)和优先级(Priority),具体如下:

属性 属性类型及用途 是否为只读属性   注意事项
编号(ID) 类型:long
用于标识不同的线程,不同的
线程拥有不同的编号
某个编号的线程运行结束后,该编号可能被后续创建的线<>程使用。不同线程拥有的编号虽然不同,但是这种编号的唯一性也只在Java虚拟机的一次运行有效。也就说重启一个Java虚拟机(如重启Web服务器)后,某些线程的编号可能与上次运行的某个线程的编号一样。因此该属性的值不适合用作某种唯一标识,特别是作为数据库中的唯一标识
名称(Name) 类型:String
用于区分不同的线程,默认值
与线程的编号有关,默认值的
格式为“Thread-线程编号”
Java并不禁止开发者将不同线程的名称设置为相同的值,但是,应该尽量去设置不同的值,来协助Debug
线程类别(Daemon) 类型:boolean
值为true表示相应的线程为守
护线程,否则表示相应的线程
为用户线程。该属性的默认值
与相应线程的父线程的该属性
的值相同
该属性必须在相应线程启动之前设置,即调用setDaemon方法必须在调用start方法之前,否则会出现IllegalThreadStateException
优先级(Priority) 类型:int
优先级高的线程一般会被优先
运行。优先级从1到10,默认值一般为5(普通优先级),对于具体的一个线程而言,其优先级的默认值与其父线程的优先级值相等
一般使用默认的优先级即可,不恰当地设置该属性值可能会导致严重的问题(线程饥饿)

  Java线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行,但并不能保证某些线程按照其优先级高低的顺序运行。
  按照线程是否会阻止Java虚拟机正常停止,可以将线程分为守护线程和用户线程。用户线程会阻止Java虚拟机的正常停止,即一个Java虚拟机只有在其所有用户线程都运行结束的情况下才能正常停止。而守护线程则不会影响Java虚拟机的正常停止,即应用程序中有守护线程在运行,也不影响Java虚拟机的正常停止。

1.4 Thread类的常用方法

方法 功能 备注
static Thread currentThread() 返回当前线程,即当前代码的执行线程 同一段代码调用该方法,其返回值可能对应着不同的线程
void run() 用于实现线程的任务处理逻辑 该方法由Java虚拟机直接调用,一般情况下应用程序不应该调用该方法
void start() 启动线程 调用该方法并不代表相应的线程已经被启动,线程是否启动是由虚拟机去决定的
void join() 等待相应线程运行结束 若线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束
static void uield() 使当前线程主动放弃其对处理器的占用,这可能导致当前线程被暂停 这个方法是不可靠的,该方法被调用时当前线程仍可能继续运行
static void sleep(long millis) 使当前线程休眠(暂停运行)指定的时间

  yield静态方法的作用相当于执行该方法的线程对线程调度器说“我现在不急,如果别人需要处理器资源的先给别人用。如果么有其他人要用,我也不介意继续占用”。
  Java本身就是一个多线程的平台。除了开发者自己创建和使用的线程外,还有由Java虚拟机创建、使用的线程。如Java虚拟机垃圾回收器负责对Java程序中不再使用的内存空间进行回收,而这个回收的动作实际上也是通过专门的线程(垃圾回收器)实现的,这些线程由Java虚拟机自行创建。从垃圾回收的角度看,Java平台中的线程可以分为垃圾回收线程和应用线程,应用线程由Java应用程序开发者创建。

1.5 线程的层次关系

  Java中的线程不是孤立的,线程与线程之间是存在联系的。比如线程A所执行的代码创建了线程B,那么习惯上就称线程B为线程A的子线程,线程A为线程B的父线程。线程间的这种父子关系,就被称为线程的层次关系。由于Java虚拟机创建的main线程(也被称为主线程)负责执行Java程序的入口main方法,因此main方法中直接创建的线程都是main线程的子线程,这些子线程所执行的代码又有可能创建其他线程。所以,线程层次关系如下:

  Java中,一个线程是否是一个守护线程默认取决于其父线程。此外,父线程在创建子线程后启动子线程之前,可以调用该线程的setDaemon方法,修改相应的线程为守护线程或用户线程。Java中,父线程和子线程之间的生命周国旗没有必然的关系,比如父线程运行结束后,子线程也可以继续运行,子线程运行结束也不妨碍父线程继续运行。
  习惯上,也可以称某些子线程为工作者线程或后台线程,工作者线程通常是其父线程创建用来专门负责某项特定任务的执行的。

1.6 线程的生命周期状态

  线程生命周期状态如下:

  线程的状态可以通过Thread.getState()来获取,该方法的返回值是一个枚举类型,线程状态定义如下:

  • NEW:一个已创建而未启动的线程处于该状态。
  • RUNNABLE:该状态可以被堪称一个复合状态。它包括两个子状态:READY和RUNNING。前者表示处于该状态的线程可以被线程调度器进行调度而使之处于RUNING装填,后者表示线程正在运行状态。执行Thread.yield()的线程,其状态可能由RUNNING转换为READY。
  • BLOCKED:一个线程发起一个阻塞式IO操作后,或者申请一个由其他线程持有的独占资源(比如锁)时,相应的线程就在该状态。处于BLOCKED状态的线程并不会占处理器资源,当阻塞式IO操作完成后,或线程获得了其申请的资源,状态又会装换为RUNNABLE。
  • WAITING:一个线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特定操作的状态。能够是线程变成WAITING状态的方法包括:Object.wait()、Thread.join()、LockSupport.park(Object),能够使线程从WAITING状态变成RUNNABLE状态的方法有:Object.notify()、Object.notifyAll()和LockSupport.unpark(Object)。
  • TIMED_WAITING:该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态。当其他线程没有在特定时间内执行该线程所期待的特定操作时,该线程的状态自动转换为RUNNABLE。
  • TERMINATED:已经执行结束的线程处于该状态。Thread.run()正常返回或由于抛出异常而提前终止都会导致相应线程处于该状态。

1.7 多线程下载的小例子

  此处用多线程从网络上下载一个文件,此处参考网上的一篇博客来实现,博客链接多线程下载的原理和基本用法。其实多线程下载文件可以简单理解为:多线程就是同时打开了多个通道对文件进行下载。多线程下载文件可以简单分为以下几步:
   1>获取目标文件的大小
    获取目标文件大小的理由是好在本地留好足量的空间来存储。
   2>确定要开启几个线程
    所开线程的最大数量=(CPU核数+1),本例子中开三个线程。
   3> 计算平均每个线程需要下载多少个字节的数据(
    理想情况下多线程下载是按照平均分配原则的,即:单线程下载的字节数等于文件总大小除以开启的线程总条数,当不能整除时,则最后开启的线程将剩余的字节一起下载。
   4>计算各个线程要下载的字节范围
    在下载过程中,各个线程都要明确自己的开始索引(startIndex)和结束索引(endIndex)。
   5>使用for循环开启子线程进行下载
   6>获取各个线程的目标文件的开始索引和结束索引的范围
   7>创建文件,接收下载的流
  示例代码如下:

package ThreadTest;

import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;

public class DownloadTest {
	private static final String path = "http://down.360safe.com/se/360se9.1.0.426.exe";
	public static void main(String[] args) throws Exception {
	    /*第一步:获取目标文件的大小*/
	    int totalSize = new URL(path).openConnection().getContentLength();
	    System.out.println("目标文件的总大小为:"+totalSize+"B");
	    
	    /*第二步:确定开启几个线程。开启线程的总数=CPU核数+1;例如:CPU核数为4,则最多可开启5条线程*/
	    int availableProcessors = Runtime.getRuntime().availableProcessors();
	    System.out.println("CPU核数是:"+availableProcessors);
	    
	    int threadCount = 3;
	    /*第三步:计算每个线程要下载多少个字节*/
	    int blockSize = totalSize/threadCount;
	
	    /*每次循环启动一条线程下载*/
	    for(int threadId=0; threadId<3;threadId++){
	        /*第四步:计算各个线程要下载的字节范围*/
	        /*每个线程下载的开始索引*/
	        int startIndex = threadId * blockSize;
	        /*每个线程下载的结束索引*/
	        int endIndex = (threadId+1)* blockSize-1;
	        /*如果是最后一条线程*/
	        if(threadId == (threadCount -1)){
	            endIndex = totalSize -1;
	        }
	        /*第五步:启动子线程下载*/
	        new DownloadThread(threadId,startIndex,endIndex).start();
	    }
	}

	private static class DownloadThread extends Thread{
	    private int threadId;
	    private int startIndex;
	    private int endIndex;
	    public DownloadThread(int threadId, int startIndex, int endIndex) {
	        super();
	        this.threadId = threadId;
	        this.startIndex = startIndex;
	        this.endIndex = endIndex;
	    }
	
	    @Override
	    public void run(){
	        System.out.println("第"+threadId+"条线程,下载索引:"+startIndex+"~"+endIndex);
	        /*每条线程要去找服务器拿取一段数据*/
	        try {
	            URL url = new URL(path);
	            HttpURLConnection connection = (HttpURLConnection)url.openConnection();
	            /*设置连接超时时间*/
	            connection.setConnectTimeout(5000);
	            /*第六步:获取目标文件的[startIndex,endIndex]范围*/
	            connection.setRequestProperty("range", "bytes="+startIndex+"-"+endIndex);
	            connection.connect();
	            /*获取响应码,当服务器返回的是文件的一部分时,响应码不是200,而是206*/
	            int responseCode = connection.getResponseCode();
	            if (responseCode == 206) {
	                //拿到目标段的数据
	                InputStream is = connection.getInputStream();
	                /*第七步:创建一个RandomAccessFile对象,将返回的字节流写到文件指定的范围*/
	                String fileName = getFileName(path);
	                /*创建一个可读写的文件,即把文件下载到D盘*/
	                RandomAccessFile raf = new RandomAccessFile("d:/"+fileName, "rw");
	                /*注意:让raf写字节流之前,需要移动raf到指定的位置开始写*/
	                raf.seek(startIndex);
	                /*将字节流数据写到文件中*/
	                byte[] buffer = new byte[1024];
	                int len = 0;
	                while((len=is.read(buffer))!=-1){
	                    raf.write(buffer, 0, len);
	                }
	                is.close();
	                raf.close();
	                System.out.println("第 "+ threadId +"条线程下载完成 !");
	            } else {
	                System.out.println("下载失败,响应码是:"+responseCode);
	            }
	        } catch (Exception e) {
	            e.printStackTrace();
	        }
	    }
	}

	/*获取文件的名称*/
	private static String getFileName(String path){
	    int index = path.lastIndexOf("/");
	    String fileName = path.substring(index+1);
	    return fileName ;
	}
}

  测试结果:

目标文件的总大小为:48695168B
CPU核数是:4
第0条线程,下载索引:0~16231721
第1条线程,下载索引:16231722~32463443
第2条线程,下载索引:32463444~48695167
第 1条线程下载完成 !
第 0条线程下载完成 !
第 2条线程下载完成 !

  下载文件如下:

1.8 多线程编程的优势和风险

  多线程编程的优势如下:

  • 1、提高系统的吞吐率
      多线程编程使得一个进程中国可以有多个并发的操作。
  • 2、提高响应性
      如对于Web应用程序而言,一个请求处理慢了并不会影响其他请求的处理。
  • 3、充分利用多核处理器资源
  • 4、简化程序的结构
      多线程编程的风险如下:
  • 1、线程安全问题
      多个线程共享数据时,如果没有采取相应的并发访问控制措施,就可能会产生数据一致性问题,如读取脏数据(过期的数据)、丢失更新(某些线程所做的更新呗其他线程所做的更新覆盖)等。
  • 2、线程活性问题
      即线程的状态出现了问题,比如死锁(某些线程一直处于等待其他线程释放锁)、活锁(一个线程在尝试某个操作单没有进展)、线程饥饿(某些线程永远不能被处理器运行)等问题。
  • 3、上下文切换
      处理器从执行一个线程转向执行另一个线程的时候,操作系统所做的一个动作是上下文切换。上下文切换是多线程编程的必然副产物,它增加了系统的消耗,不利于系统的吞吐率。
  • 4、可靠性
      线程是进程的一个组件,它总是存在于特定的进程中的,如果这个进程由于某个特定的原因意外提前终止,那么该进程中的所有线程也无法随之继续运行。因此,从提高软件可靠性的角度来看,某些情况可能要考虑多进程多线程的编程方式,而非简单的单进程多线程方式。

二、多线程竟态与特性

2.1 串行、并发与并行

  要理解串行、并发和并行,可以先看一张图:

  从图中可以看出:串行是一个资源,依次、首尾相接地把不同的事情做完;并发也是利用一个资源,在做一件事时的空闲时间去做另一件事;并行是投入多个资源,去做多件事。
  从软件的角度来说,并发就是在一段时间内以交替的方式去完成多个任务而并行就是以齐头并进的方式去完成多个任务。从硬件的角度来说,在一个处理器一次只能运行一个线程的情况下,由于处理器可以使用时间片分配的技术来实现在同一时间段内运行多个线程,因此一个处理器就可以实现并发。
  多线程编程的实质就是将任务的处理方式由串行改成并发。

2.2 竟态

  一个计算结果的正确性与时间有关的现象被称为竟态。
  先看一个demo,这个demo是要生成40个递增的序列代码,如下:

/*生成下一个序列号的接口*/
public interface CircularSeqGenerator {
	/*下一个序列号*/
	short nextSequence();
}
/*生成下一个序列号的实现类*/
public final class RequestIDGenerator implements CircularSeqGenerator {

  private final static RequestIDGenerator INSTANCE = new RequestIDGenerator();
  private final static short SEQ_UPPER_LIMIT = 999;
  private short sequence = -1;

  /*生成循环递增序列号*/
  public short nextSequence() {
    if (sequence >= SEQ_UPPER_LIMIT) {
      sequence = 0;
    } else {
      sequence++;
    }
    return sequence;
  }

  /*生成一个新的Request ID*/
  public String nextID() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss");
    String timestamp = sdf.format(new Date());
    DecimalFormat df = new DecimalFormat("000");

    // 生成请求序列号
    short sequenceNo = nextSequence();

    return "0049" + timestamp + df.format(sequenceNo);
  }

  /*返回该类的唯一实例*/
  public static RequestIDGenerator getInstance() {
    return INSTANCE;
  }
}
/*测试类*/
public class RaceConditionDemo {

	  public static void main(String[] args) throws Exception {
	    // 客户端线程数
	    int numberOfThreads = args.length > 0 ? Short.valueOf(args[0]) : Runtime
	        .getRuntime().availableProcessors();
	    Thread[] workerThreads = new Thread[numberOfThreads];
	    for (int i = 0; i < numberOfThreads; i++) {
	      workerThreads[i] = new WorkerThread(i, 10);
	    }

	    // 待所有线程创建完毕后,再一次性将其启动,以便这些线程能够尽可能地在同一时间内运行
	    for (Thread ct : workerThreads) {
	      ct.start();
	    }
	  }

	  // 模拟业务线程
	  static class WorkerThread extends Thread {
	    private final int requestCount;

	    public WorkerThread(int id, int requestCount) {
	      super("worker-" + id);
	      this.requestCount = requestCount;
	    }

	    @Override
	    public void run() {
	      int i = requestCount;
	      String requestID;
	      RequestIDGenerator requestIDGen = RequestIDGenerator.getInstance();
	      while (i-- > 0) {
	        // 生成Request ID
	        requestID = requestIDGen.nextID();
	        processRequest(requestID);
	      }
	    }

	    // 模拟请求处理
	    private void processRequest(String requestID) {
	      // 模拟请求处理耗时
	      try {
			Thread.sleep(50);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	      System.out.printf("%s got requestID: %s %n",
	          Thread.currentThread().getName(), requestID);
	    }
	  }
}

  以上程序运行时,有些结果是对的,有时则会出现以下的结果:

  当出现如上图的结果时,就说明出现了竟态。
  共享变量:可以被多个线程共同访问的变量。
  当多个线程同时访问、修改共享变量时,就有可能出现竟态现象,也可能会出现读取脏数据问题,即线程读取到一个过时的数据、丢失更新问题。竟态不一定就导致计算结果的不正确,只是不排除计算结果有时正确、有时错误的可能
  竟态有两种模式:read-modify-write(读-改-写)和check-then-act(检测而后行动)。

  • read-modify-write
      该操作可以被分为这样几个步骤:读取一个共享变量的值,然后根据该值做一些计算,接着更新共享变量的值。
  • check-then-act
      该操作可以被分为以下几个步骤:读取某个共享变量的值,然后决定下一步的动作是什么。
      一般而言,如果一个类在单线程环境下能正常运行,并且在多线程环境下,在适用房并不比为其做任何改变的情况下也能正常运行,那么就称其是线程安全的,相应地称这个类具有线程安全性。反之,一个类在单线程情况下能正常运行,但在多线程环境下无法正常运行,那么这个类就是非线程安全的。线程安全问题概括来说表现为3个方面:原子性、可见性和有序性

2.3 原子性

  对于涉及共享变量的操作,若该操作从其执行线程意外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性。
  在理解原子操作时有两点需要注意:
   1>原子操作是针对共享变量的操作而言的。也就是说,仅涉及局部变量访问的操作是无所谓是否具有原子性的。
   2>原子操作是从该该操作的执行线程以外的线程来描述的,也就是说它在多线程环境下才有意义。
  原子操作的“不可分割”具有两层含义:

  • 1、访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行部分的中间效果。
  • 2、访问同一组共享变量的原子操作是不能够被交错的。
      Java有两种方式可以实现原子性。一种是软件层面上,就是使用锁,锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能被一个线程访问;另一个是使用处理器的CAS指令,原理与锁相同,不过是在硬件(处理器和内存)层面实现。
      在Java中,对基本数据类型数据(long/double除外,仅包括byte、boolean、short、char、float和int)的变量和引用型变量的写操作都是原子的
      如果要保证long/double的写操作具有原子性,可以使用volatile变量修饰long/double变量即可。值得注意的是:volatile关键字仅能保证变量写操作的原子性,并不能保证其他操作(如read-modify-write操作和check-then-act操作)的原子性
      Java中任何变量的读操作都是原子操作。
      从原子的“不可分割”特性可知,使一个操作具有原子性就可以消除该操作导致竟态的可能性。

2.4 可见性

  如果一个线程对某个共享变量进行更新后,后续访问该变量的线程可以读取到本次更新的结果,那么就称这个线程对该共享变量的更新对其它线程可见。因此,可见性就是指一个线程对共享变量的更新结果对于读取相应共享变量的线程而言是否可见的问题。
  程序中的变量可能会被分配到寄存器而不是主内存中进行存储。
  处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器、高速缓存、写缓冲器和无效化队列等部件执行读、写操作的。
  为了保证可见性,必须使一个处理器对共享变量所做的更新最终被写入到该处理器的高速缓存或主内存中,这个过程称为冲刷处理器缓存。一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或主内存中对相应的变量进行缓存同步,这个过程称为刷新处理器缓存。因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。
  volatile关键字就可以保证可见性,主要通过两方面来实现:

  • 1、提示JIT编译器修饰的变量可能被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。
  • 2、读取一个volatile变量会使相应的处理器执行刷新处理器缓存的动作,写一个volatile变量会使其相应的处理器执行冲刷处理器缓存的动作。
      对于一个共享变量而言,一个线程更新了该变量的值以后,其他线程能读取到这个更新后的值,那么这个值就被称为该变量的相对新值。如果读取这个共享变量的线程在读取并使用该变量的时候,其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。可见性的保障仅仅意味着一个线程能读取到共享变量的相对新值,而不能保障该线程能够读取到相应变量的最新值
      Java中默认的两种可见性的存在场景:
  • 1、父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。
  • 2、一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。

2.5 有序性

  有序性指的是这样一种现象:Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
  顺序结构是结构化编程中的一种基本操作,它表示希望某个操作必须先于另外一个操作执行。但是在多核处理器上,编译器可能改变两个操作的先后顺序处理器可能不是完全按照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致,这种现象就叫重排序
  重排序是对内存访问有关操作(读写)的一种优化,它可以在不影响单线程正确性的情况下,提升程序的性能。但是,它可能对多线程程序的正确性产生影响(如线程安全问题),当然,重排序也不是必然出现的。
  产生重排序的可能来源有几个:编译器(JIT编译器)、处理器和存储子系统(包括写缓冲器、高速缓存)。
  先看几个与内存操作顺序有关的术语:

  • 1、源代码顺序:源代码中所指定的内存访问顺序。
  • 2、程序顺序:在给定处理器上运行的目标代码所指定的内存访问操作顺序。
  • 3、执行顺序,内存访问操作在给定处理器上的实际执行顺序。
  • 4、感知顺序:在给定处理器上感知到(看到)的该处理器及其他处理器的内存访问操作发生的顺序。
      基于以上内容,可以重排序问题再进行细分:

2.5.1 指令重排序

  在源代码顺序和程序顺序不一致,或程序顺序与执行顺序不一致时,就称发生了指令重排序。

Java平台包含两种编译器:静态编译器((javac)和动态编译器(JIT编译器)。前者的作用是将Java源代码(.java文件)编译为字节码(.class二进制文件),是在代码编译阶段介入的;后者的作用是将字节码动态便以为Java虚拟机宿主机的本地代码(机器码),是在Java程序运行过程中介入的。

  编译器出于性能的考虑,在其认为不影响程序(单线程程序)正确性的情况下可能对源代码顺序进行调整,从而造成程序顺序与相应的源代码顺序不一致的情况。Java平台中静态编译器基本上不会执行指令重排序,而JIT编译器可能会进行指令重排序
  重排序的两个特征:
   1>重排序可能会导致线程安全问题。
   2>重排序不是必然出现的。
  处理器也可能执行指令重排序,使执行顺序与程序顺序不一致。处理器对指令进行重排序也称为处理器的乱序执行

  现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令。在乱序执行的处理器中,指令是一条一条按照程序顺序被处理器读取的,谈话这些指令中哪条就绪哪条就会被先执行,而不是完全按照程序顺序执行。这些指令的执行结果会先被存入重排序缓冲器,而不是直接被写入寄存器或主内存。重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交到寄存器或内存中去。在乱序执行的情况下,尽管指令的执行顺序可能没安全按照程序顺序,但是由于指令的执行结果的提交仍然是按照程序顺序来的,因此处理器的指令重排序并不会对单线程程序的正确性产生影响。
  处理器的乱序执行还采用了一种猜测执行的技术,就是猜测执行不同条件下的语句,这样就会造成if语句的语句体咸鱼其条件语句执行的效果,即可能导致指令重排序。

  由上面的知识可以看出,处理器的指令重排序并不会对单线程程序的正确性产生影响,但它可能会导致多线程程序出现非预期的结果

猜你喜欢

转载自blog.csdn.net/m0_37741420/article/details/107970876