关注「Java视界」公众号,获取更多技术干货

Java内存模型与线程

实现多线程我们知道可以继承Thread、实现Runnable接口等,但是为什么就实现呢?这篇文章就是解释多线程实现的底层原理。

一、主内存和工作内存

 Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。

线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图。

Java 内存模型也主内存和工作内存之间的交互操作:
  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store write 操作。

 

二、原子性、可见性、有序性(并发编程三要素)

2.1 原子性

Java 内存模型来直接保证的原子性变量操作包括 read load assign use store write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(是 long double除外)。
 
为了保证一个用户自己定制的原子性范围,比如需要某个方法、某个代码块具有原子性,那就需要synchronized关键字了。synchronized关键字可以看成是比较高层次的字节码指令,底层就是把 lock unlock 操作。

 

2.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。volatile修饰变量时其实就是使得这个变量具有可见性。无论变量是否被volatile修饰,Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。不同的是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
 
除了volatile之外, Java 还有两个关键字能实现可见性,它们是 synchronized final
 

2.3 有序性

有序性,即程序的执行顺序按照代码的先后顺序来执行,如果在本线程内观察,所有的操作都是有序的

 

三、volatile

上面也大致讲了volatile保证可见性的底层原因。

当一个变量被定义成 volatile 之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“ 可见性 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
 
而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如, 线程A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程B 可见。
 
需要注意的是:volatile可以保证可见性,但却不能保证原子性。
volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题)。 但是Java里面的运算操作符并非原子操作,这导致volatile 变量的运算在并发下一样是不安全的。运算操作符都是由成多个指令组成的比如 ++ 自增操作,由下面的字节码组成:

Stack=2, Locals=0, Args_size=0 
0: getstatic
3: iconst_1 
4: iadd 
5: putstatic

用volatile修饰 i ,然后进行自增:

public static volatile int i = 0;

i++;

当把 i 的值取到栈顶时,volatile关键字保证了 i 的值在此时是正确的,但是在执行iconst_1iadd这些指令的时候,其他线程可能已经把 i 的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的 值同步回主内存中。volatitle关键字用于一写多读的情景,即一个操作线程,多个读的线程。

四、线程相关问题汇总

4.1 Runnable和Callable的区别

两者都需要调用Thread.start()启动线程;Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。

因为Callable的call方法提供Future类型的返回值,所以当你需要知道任务执行的结果时,Callable是个不错的选择。

Runnable

Callable

4.2 Callable如何获取返回值?

Callalble接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

public class Main {
  public static void main(String[] args) throws InterruptedException, ExecutionException {
  ExecutorService executor = Executors.newFixedThreadPool(2);
  //创建一个Callable,3秒后返回String类型
  Callable myCallable = new Callable() {
    @Override
    public String call() throws Exception {
      Thread.sleep(3000);
      System.out.println("calld方法执行了");
      return "call方法返回值";
    }
  };
  System.out.println("提交任务之前 "+getStringDate());
  Future future = executor.submit(myCallable);
  System.out.println("提交任务之后,获取结果之前 "+getStringDate());
  System.out.println("获取返回值: "+future.get());
  System.out.println("获取到结果之后 "+getStringDate());
  }
  public static String getStringDate() {
    Date currentTime = new Date();
    SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss");
    String dateString = formatter.format(currentTime);
    return dateString;
    }
  }
}

结果:

 

4.3 get()方法的阻塞性:

在调用submit提交任务之后,主线程本来是继续运行了。但是运行到future.get()的时候就阻塞住了,一直等到任务执行完毕,拿到了返回的返回值,主线程才会继续运行。

因为调用get()方法时,任务还没有执行完,所以会一直等到任务完成,形成了阻塞。任务是在调用submit方法时就开始执行了,如果在调用get()方法时,任务已经执行完毕,那么就不会造成阻塞。

System.out.println("提交任务之前 "+getStringDate());
Future future = executor.submit(myCallable);
System.out.println("提交任务之后 "+getStringDate());
Thread.sleep(4000);
System.out.println("已经睡了4秒,开始获取结果 "+getStringDate());
System.out.println("获取返回值: "+future.get());
System.out.println("获取到结果之后 "+getStringDate());

因为睡了4秒,任务已经执行完毕,所以get方法立马就得到了结果。

submit两个任务时,总阻塞时间是最长的那个。例如,有两个任务,一个3秒,一个5秒:

Callable myCallable = new Callable() {
  @Override
  public String call() throws Exception {
  Thread.sleep(5000);
  System.out.println("calld方法执行了");
  return "call方法返回值";
  }
};
Callable myCallable2 = new Callable() {
  @Override
  public String call() throws Exception {
  Thread.sleep(3000);
  System.out.println("calld2方法执行了");
  return "call2方法返回值";
  }
};
System.out.println("提交任务之前 "+getStringDate());
Future future = executor.submit(myCallable);
Future future2 = executor.submit(myCallable2);
System.out.println("提交任务之后 "+getStringDate());
System.out.println("开始获取第一个返回值 "+getStringDate());
System.out.println("获取返回值: "+future.get());
System.out.println("获取第一个返回值结束,开始获取第二个返回值 "+getStringDate());
System.out.println("获取返回值2: "+future2.get());
System.out.println("获取第二个返回值结束 "+getStringDate());
提交任务之前 14:14:47
提交任务之后 14:14:48
开始获取第一个返回值 14:14:48
calld2方法执行了
calld方法执行了
获取返回值: call方法返回值
获取第一个返回值结束,开始获取第二个返回值 14:14:53
获取返回值2: call2方法返回值
获取第二个返回值结束 14:14:53

获取第一个结果阻塞了5秒,所以获取第二个结果立马就得到了。

 

4.4 submit(Runnable task):

因为Runnable是没有返回值的,所以如果submit一个Runnable的话,get得到的为null。

Runnable myRunnable = new Runnable() {
  @Override
  public void run() {
  try {
    Thread.sleep(2000);
    System.out.println(Thread.currentThread().getName() + " run time: " + System.currentTimeMillis());
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
  }
};

Future future = executor.submit(myRunnable);
System.out.println("获取的返回值: "+future.get());
pool-1-thread-1 run time: 1493966762524
获取的返回值: null

 

4.5  submit(Runnable task, T result)

虽然submit传入Runnable不能直接返回内容,但是可以通过submit(Runnable task, T result)传入一个载体,通过这个载体获取返回值。这个其实不能算返回值了,是交给线程处理一下。

先新建一个载体类Data:

public static class Data {
  String name;
  String sex;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public String getSex() {
    return sex;
  }

  public void setSex(String sex) {
    this.sex = sex;
  }
}

然后在Runnable的构造方法中传入:

static class MyThread implements Runnable {
  Data data;

  public MyThread(Data name) {
    this.data = name;
  }

  @Override
  public void run() {
    try {
      Thread.sleep(2000);
      System.out.println("线程 执行:");
      data.setName("新名字");
      data.setSex("新性别");
   } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

然后调用:

Data data = new Data();
Future<Data> future = executor.submit(new MyThread(data), data);
System.out.println("返回的结果 name: " + future.get().getName()+", sex: "+future.get().getSex());
System.out.println("原来的Data name: " + data.getName()+", sex: "+data.getSex());
线程 执行:
返回的结果 name: 新名字, sex: 新性别
原来的Data name: 新名字, sex: 新性别

发现原来的data也变了。

 

4.6  get(long var1, TimeUnit var3)

前面都是用的get()方法获取返回值,那么因为这个方法是阻塞的,有时需要等很久。所以有时候需要设置超时时间。

get(long var1, TimeUnit var3)这个方法就是设置等待时间的。

如下面的任务需要5秒才能返回结果:

Callable myCallable = new Callable() {
  @Override
  public String call() throws Exception {
    Thread.sleep(5000);
    return "我是结果";
  }
};
Future future1 = executor.submit(myCallable);
System.out.println("开始拿结果 "+getStringDate());
System.out.println("返回的结果是: "+future1.get()+ " "+getStringDate());
System.out.println("结束拿结果 "+getStringDate());
开始拿结果 16:00:43
返回的结果是: 我是结果 16:00:48
结束拿结果 16:00:48

现在要求最多等3秒,拿不到返回值就不要了,所以用get(long var1, TimeUnit var3)这个方法

方法的第一个参数是长整形数字,第二个参数是单位,跟线程池ThreadPoolExecutor的构造方法里一样的。

Future future1 = executor.submit(myCallable);
System.out.println("开始拿结果 "+getStringDate());
try {
  System.out.println("返回的结果是: "+future1.get(3, TimeUnit.SECONDS)+ " "+getStringDate());
} catch (TimeoutException e) {
  e.printStackTrace();
  System.out.println("超时了 "+getStringDate());
}
System.out.println("结束拿结果 "+getStringDate());

然后输出是

 

过了三秒就抛出超时异常了,主线程继续运行,不会再继续阻塞。

五、什么是守护线程?

在java多线程开发中,有两类线程,分别是User Thread(用户线程)和Daemon Thread(守护线程) 。

守护线程又被称为“服务进程”、“精灵线程”或“后台线程”,是指在程序运行时在后台提供一种通用服务的线程,比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。通俗点讲,任何一个守护线程都是整个JVM中所有非守护线程的“保姆”,守护线程,类似于操作系统里面是守护进程。

5.1 用户线程与守护线程的区别

JVM 程序在什么情况下能够正常退出?答:当 JVM 中不存在任何一个正在运行的非守护线程时,则 JVM 进程即会退出。

事实上,User Thread(用户线程)和Daemon Thread(守护线程)从本质上来说并没有什么区别,唯一的不同之处就在于虚拟机的离开:如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。

5.2 区分用户线程和守护线程

isDaemon( ),返回true为守护线程,反之为用户线程

5.3 手动设置守护线程

守护线程并非只有虚拟机内部可以提供,用户也可以手动将一个用户线程设定/转换为守护线程。

在调用start( )前调用对象的setDeamon(true)方法,若将参数改为false,则表示的是用户线程模式。

注意:当在一个守护线程中产生了其他线程,那么这些新产生的线程默认还是守护线程。

class ThreadDemo extends Thread{
	@Override
	public void run() {
		System.out.println(Thread.currentThread().getName()+":begin");
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName()+":end");
	}
}

public class Test {

	public static void main(String[] args) {
		System.out.println("test3:begin");
		Thread t1 = new ThreadDemo();
		t1.setDaemon(true);
		t1.start();
		System.out.println("test3:end");
	}
}

//test3:begin
//test3:end
//Thread-0:begin

从运行结果中可以发现,没有输出Thread-0:end。因为在启动线程前将其设置为守护线程了,当程序中只有守护线程存在时,JVM是可以退出的,也就是说,当JVM中只有守护线程运行时,JVM会自动关闭。因此,当test3方法调用结束后,main线程将退出,此时线程t1还处于休眠状态没有运行结束,但是由于此时只有这个守护线程在运行,JVM将会关闭,因此不会输出Thread-0:end。

5.4 使用守护线程的注意事项

 (1)必须在线程运行前设置是否为守护线程

thread.setDaemon(true)必须在thread.start()之前设置。否则将引发IllegalThreadStateException异常。这意味着正在运行的常规线程不能设置为守护进程线程。

(2)守护线程创建的新线程也是守护线程

(3)守护线程不要去操作固有资源

并非所有用户线程都可以分配给守护线程进行服务,例如读写操作或计算逻辑。因为这个应用程序可能在DaemonThread有时间操作之前就退出了虚拟机。这意味着守护进程线程永远不应该访问固有的资源,例如文件和数据库,因为它可以在任何时候被中断,甚至在操作的中间。

5.5  守护线程优雅地停止用户线程

虽然java的Thread类里面,提供了很多让线程停止和销毁的方法,但早在jdk1.2版本就不推荐使用了。主要原因是强制停止线程容易造成死锁。对操作中的数据非常不友好。但守护线程很好地解决了这一问题。通过守护线程,可以优雅地停止用户线程。

public class DaemonTest1 {

    public static boolean flag = true;

    static class MyThread implements Runnable {
        @Override
        public void run() {
            while (flag) {
                for (int i = 0; i < 5; i++) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() +" " + (i+1));
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new MyThread(), "用户线程");
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = false;
        }, "守护线程!");
        t2.setDaemon(true);

        t1.start();
        t2.start();
    }
}
用户线程 1
用户线程 2
用户线程 3
用户线程 4
用户线程 5
用户线程 1
用户线程 2
用户线程 3
用户线程 4
用户线程 5

在main方法中,启动了两个线程,t1为用户线程,如果flag标志为true,则进行循环报数,每50毫秒报数1-5。t2为守护线程,100毫秒后,将flag标志置为false,也就让t1停止报数。当t1停止报数后,t1线程运行结束,由于没有其他线程t2守护线程运行结束。

在100毫秒的时间内,t1用户线程报了两轮数。然后整个线程运行结束。

 

 

 

 

posted @ 2022-06-25 14:02  沙滩de流沙  阅读(76)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货