Java多线程简介及线程同步的本质原理
- 前言
- 目录
- 1 多线程基础
- 2 线程同步
- 3 synchronized
- 4 volatile
- 5 读写锁
- 6 Atomic 包
- 7 面试题
- 参考文章
前言
今天主要学习 Java 多线程中线程安全的相关知识,主要包括简单介绍线程的创建、详细讲解同步的原理及读写锁等其他基础知识。对于多年 Java 开发老司机,可以跳过线程创建部分的知识。
目录
1 多线程基础
1.1 进程与线程
面试题:说一说你对线程和进程的理解
- 进程是资源分配的最小单位,线程是程序执行的最小单位。
- 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
- 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
- 多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程虽然不会死掉,但是功能会受影响,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。
用生活中的场景来比喻的话呢,就是假设你住在一个小区,这个小区就是一个操作系统,你家就是一个进程,你家的柴米油盐是不跟其他户人家共享的,为什么?因为你们互相之间没关系。这个柴米油盐就是资源。
线程就是你们这个家的人,你们互相之间同时运行,可以同时干自己的事情。
1.2 线程创建的方式
线程创建的方式主要包括:
- 继承 Thread 类创建线程
/**
* 使用 Thread 类来定义工作
*/
static void thread() {
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("Thread started!");
}
};
thread.start();
}
- 实现 Runnable 接口创建线程
/**
* 使用 Runnable 类来定义工作
*/
static void runnable() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Thread thread = new Thread(runnable);
thread.start();
}
- 使用 Callable 和 Future 创建线程
Callable 是有返回值的 Runnable。
static void callable() {
Callable<String> callable = new Callable<String>() {
@Override
public String call() {
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Done!";
}
};
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> future = executor.submit(callable);
try {
String result = future.get(); //get是一个阻塞方法,虽然你换了个线程,但是你取数据的时候还是会卡住
System.out.println("result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
feature.get() 是一个阻塞方法,那么有没有办法不卡住线程呢? 答案是有的,那就是循环去查:
Future<String> future = executor.submit(callable);
try {
while(!future.isDone){
//检查是否已经完成,如果否,那么可以让主线程去做其他操作,不会被阻塞
}
String result = future.get(); //get是一个阻塞方法,虽然你换了个线程,但是你取数据的时候还是会卡住
System.out.println("result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
- Executors
JDK 1.5 后引入的 Executor 框架的最大优点是把任务的提交和执行解耦。通过 Executors 的工具类可以创建以下类型的线程池:
下面介绍常用的两种线程池。
FixThreadPool
创建固定大小的线程池。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。适用于集中处理多个任务。举个例子:
如果现在我需要优先处理一下图片,但是处理完就释放掉这些线程,那么代码可以这么写。
ExecutorService imageProcessor = Executor.newFixedThreadPool(); //我需要你马上给我很多个线程,然后一旦用完我就不要了
List<Image> images; //图片集合
for(Image image : images){
//处理图片
improcessor.excutor(iamgeRunnable,image);
}
//等图片处理完成后终止线程
imageProcessor.shutdown();
cacheThreadPool:缓存线程池
当提交任务速度高于线程池中任务处理速度时,缓存线程池会不断地创建线程,适用于提交短期的异步小程序,以及负载较轻的服务器。
static void executor() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Executor executor = Executors.newCachedThreadPool();
executor.execute(runnable);
executor.execute(runnable);
executor.execute(runnable);
}
- shutdown 和 shutdownNow 方法的使用
Executor 接口里面有两个重要的方法,一个是 shutdown,一个是 shutdownNow。
它们两个的区别是:shutdown 不再允许扔新的 runnable 进来。shutdownNow 不只是新的不允许,就算是正在执行的任务也不允许再继续执行。
✋辟谣:
网上有一个说法是:创建的线程池大小,取决于 CPU 的核数。 比如,你 CPU 有 8 个核,就创建 8 个线程,每个线程分配给你一个核,这样想想很有道理。但是其实是没道理的!你有 8 个核,你就占了所有的核了吗?不是这样的。不过,你的线程数跟你的 CPU 挂钩是有道理,它可以让你的软件在不同机器上表现相对一致。
所以,你的线程数跟你的 CPU 挂钩有道理,但是线程数 = CPU 核数就没道理了。大家记住了吧。
2 线程同步
线程同步主要包括以下内容:
2.1 JVM 内存模型
在说明 synchronized 为什么能保证线程安全之前,我们先简单过一下 JVM 内存模型。
Java 的内存模型有以下特点:
- Java 所有变量都存储在主内存中。
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本。
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接在主内存读写。
- 不同线程之间无法访问其他线程内存中的变量,线程间变量值的传递需要通过主内存来完成。线程 1 对共享变量的修改,要想被线程 2 及时看到,必须经过如下 2 个过程:
- 把工作内存 1 中更新过的共享变量刷新到主内存中;
- 将主内存中最新的共享变量的值更新到工作内存 2 中。
2.2 可见性
如果一个线程对共享变量的修改,能够被其他线程看到,那么就说此时是可见的。
2.3 原子性
原子性也就是不可再分,不能再分为分步操作。比如:
int a =1 ;//是原子操作
a+= 1;//不是原子操作
a+=1 实际分为三步:
1. 取出a = 1
2. 计算a + 1
3. 将计算结果写入内存
2.4 重排序
在 Java 中,代码书写的顺序并不等于代码执行的顺序。有时候,编译器或者处理器为了能提高程序性能,会对代码指令进行重排序。
重排序不会给单线程带来内存可见性问题,但是在进行多线程编程时,重排序可能会造成内存可见性问题。
举个例子:
int num1= 1; //第一行代码
int num2 = 2; //第二行代码
int sum = num1 + num2; //第三行代码
//在进行重排序的时候,如果将sum = num1 + num2 先于前两行代码执行,此时计算结果就会出错;
3 synchronized
3.1 作用
synchronized 可以保证在同一时刻只有一个线程执行被 synchronized 修饰的方法/代码,即保证操作的原子性和可见性。
3.2 基本使用
synchronized 可以被用在三个地方:
- 实例方法
- 静态方法
- 代码块
下面我们通过代码来进行实践一下。
3.2.1 synchronized 作用于实例方法
当没有明确给 synchronized 指明锁时,默认获取到的是对象锁。
public synchronized void Method1(){
System.out.println("我是对象锁也是方法锁");
try{
Thread.sleep(500);
} catch (InterruptedException e){
e.printStackTrace();
}
}
3.2.2 synchroinzed 作用于静态方法
// 类锁:锁静态方法
public static synchronized void Method1(){
System.out.println("我是类锁");
try{
Thread.sleep(500);
} catch (InterruptedException e){
e.printStackTrace();
}
}
3.2.3 synchronized 作用于代码块
// 类锁:锁静态代码块
public void Method2(){
synchronized (Test.class){
System.out.println("我是类锁");
try{
Thread.sleep(500);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
// 对象锁
public void Method(){
synchronized (this){
System.out.println("我是对象锁");
try{
Thread.sleep(500);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
3.3 工作原理
synchronized 的工作流程是:
- 获取互斥锁,清空工作内存中的共享变量的值
- 在主内存中拷贝最新变量的副本到工作内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存中
- 释放互斥锁
synchronized 能够实现原子性和可见性,本质上依赖的是底层操作系统的互斥锁机制。
3.4 单例写法讨论
大家平时在写单例模式的时候,肯定知道用双重锁的方式,那么,为什么不用下面这种方式,这种方式存在什么缺点?
static synchroinzed SingleMan newInstance(){
if(sInstance = null){
sInstance= new SingleMan();
}
}
这个写法有什么坏处呢?
坏处是,把 synchronized 加上方法上时,作用的是整个对象的资源,当其他访问这个对象中的其他资源时,也需要等待。代价非常大。
举个例子:
public synchronized void setX(int x){
this.x = x;
}
public synchronized int getY(){
return this.y;
}
//当调用setX方法时,如果此时有其他线程想要调用getY方法,那么需要进行等待,因为此时锁已经被当前线程拿了。所以如果把synchroinzed加在方法上时,就算操作的不是相同的资源,也需要等待。代价比较大。
那么,好的单例模式的写法是什么呢?答案是不要使用对象锁,使用局部锁:
private static volatile SingleMan sInstance; //这里为什么要用volatile呢?因为有些对象在还没初始化完成的时候,对外就已经暴露不为空,但是此时还不能用,如果此时有线程使用了这个对象,就会有问题。加入volatile就可以同步状态
static SingleMan newInstance(){
if(sInstance = null){ //可能有两个线程同时到了这个地方,都觉得是空,然后可能会同时去尝试拿monitor,然后另外一个进入等待,当对象初始化后,等待的线程往下走,此时就已经不为空。所以,需要双重检查
synchroinzed(SingleMan.class){
if(sInstance = null){
sInstance= new SingleMan();
}
}
}
}
4 volatile
4.1 基本使用
volatile 关键字只能用于修饰变量,无法用于修饰方法。并且 volatile 只能保证可见性,但不能保证操作的原子性。
在具体编程中体现为:volatile 只能保证基本类型以及一般对象的引用赋值是线程安全的。
举个例子:
volatile User user;
private void setUserName(String userName){
user.name = userName;//不安全的
}
private void setUser(User user){
this.user = user;//安全的,只能保证引用
}
4.2 工作原理
为什么 volatile 只能保证可见性,不能保证原子性呢?这跟它的工作原理有关。
线程写 volaitle 变量的步骤为:
- 改变线程工作内存中 volatile 变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
线程读 volatile 变量的步骤为:
- 从主内存读取 volatile 变量的最新值到线程的工作内存中
- 从工作内存中读取 volatile 变量的副本
由于在整个过程没有涉及到锁相关的操作,所以无法保证原子性,但是由于实时刷新了主内存中的变量值,因此任何时刻,不同线程总能看到该变量的最新值,保证了可见性。
下面出个练习来练练手。有下面这么一句代码:
private volatile int number =0;
问:当创建 500 个线程同时操作 number++ 时,是否能保证最终打印的值是 500?
答案:不能,因为 number++ 不是原子操作,而 volatile 无法保证原子性。
那要如何改呢?
解法1:synchronized关键字
synchronized(this){
number++;
}
解法2:使用ReentrankLock
private ReentrankLock lock = new ReentrankLock();
lock.lock();
try{
number++;
}finally{
lock.unlock();
}
解法3: 将int改成AtomicIntege
4.3 volatile 适用场合
要在多线程中安全的使用 volatile 变量,必须同时满足:
- 对变量的设置操作不依赖其当前值
- 不满足举例:number++、count = count + 5
- 满足举例: boolean 变量等
- 该变量没有包含在具有其他变量的不等式中
- 不满足举例:不变时 low < up
在实际项目中,由于很多情况下都不满意 volatile 的使用条件,所以 volatile 使用的场景并没有 synchronized 广。
4.4 synchronized 和 volatile 比较
4.5 注意事项
在 Java 中,对 64 位(long、double)变量的读写可能不是原子操作,因为 Java 内存模型允许 JVM 将没有被 volatile 修饰的 64 位数据类型的读写操作划分为两次 32 位的读写操作来进行。
因此导致有可能会出现读取到“半个变量”的情况,解决方案是:加 volatile 关键字。
这里有同学可能会问啦,不是说 volatile 不保证原子性吗?为什么对于 64 位类型的变量用 volatile 修饰?
原因是:volatile 本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是 java 的内存模型保证声明为 volatile 的 long 和 double 变量的 get 和 set 操作是原子的
小伙伴们记住了没~
5 读写锁
请大家先思考以下问题,对于一个公共变量,如果:
- 同时两个线程都在写,会出问题吗?
- 当一个线程在对变量进行写的时候,有另外一个线程想读呢?会出问题吗 ?
- 当一个线程在对变量进行读的时候,有另外一个线程写呢?会出问题吗?
- 当一个线程在对变量进行读的时候,有另外一个线程也准备读呢?会出问题吗?
答案是: 1、2、3 会出问题,4 不会出问题。
1、2、3 出问题的原因在于有一个线程对变量进行了修改,此时会导致数据发生改变,如果有另外一个线程要进行读取,会出现读取的数据可能出错。
但是,当两个线程同时进行读操作的时候,是 OK 的,不会出现你读出来是个 1,我读出来是个 2 的问题。
因为,当多个线程同时进行读操作的时候,我们就没有必要进行同步,浪费资源。为了减少这种资源浪费,读写锁就出现了~
5.1 读写锁的定义
读写锁维护了一对锁,一个读锁和一个写锁,同一时刻,可以有多个线程拿到读锁,但是只有一个线程拿到写锁。
总结起来为:读读不互斥,读写互斥,写写互斥。
5.2 读写锁的使用
读写锁在 Java 中是 ReentrantReadWriteLock,使用方式是:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo implements TestDemo {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
private int x = 0;
private void count() {
writeLock.lock();
try {
x++;
} finally {
writeLock.unlock();// 保证当读的时候如果出现异常,会释放锁,synchronized为什么不用呢?因为synchronized内部已经帮我们做了~
}
}
private void print(int time) {
readLock.lock();
try {
for (int i = 0; i < time; i++) {
System.out.print(x + " ");
}
System.out.println();
} finally {
readLock.unlock();// 保证当读的时候如果出现异常,会释放锁,synchronized为什么不用呢?因为synchronized内部已经帮我们做了~
}
}
@Override
public void runTest() {
}
}
6 Atomic 包
这个包里的类本身就被设计成原子的,可以方便我们实现线程安全。比如:
int count ;
//如果你想保证count++是安全的,但是不想用synchronized,那么使用AtomicInteger;
7 面试题
好了,到这里本篇文章就已经结束了。在这次的文章中,我们主要简单介绍了线程和进程,详细了解了 synchronized 和 volatile 的工作原理,并对他们两者的使用场景进行了比较。
相信你对多线程应该已经稍微熟悉一点了,现在来几道面试练练手,加深印象吧~