转载

多线程同步原理

  • 今天主要学习Java多线程中线程安全的相关知识,主要包括简单介绍线程的创建、详细讲解同步的原理以及读写锁等其他基础知识。对于多年Java开发老司机,可以跳过线程创建部分的知识。
  • 现在我们发车了~

目录

多线程同步原理

一、多线程基础

1.1 进程与线程

null

面试题: 说一说你对线程和进程的理解

  • 进程是资源分配的最小单位,线程是程序执行的最小单位。
  • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此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 的工具类可以创建以下类型的线程池:

多线程同步原理
下面介绍常用的两种线程池: (1)FixThreadPool : 创建固定大小的线程池。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。 适用于集中处理多个任务

。 举个例子:如果现在我需要优先处理一下图片,但是处理完就释放掉这些线程,那么代码可以这么写:

ExecutorService imageProcessor = Executor.newFixedThreadPool(); //我需要你马上给我很多个线程,然后一旦用完我就不要了

List<Image>  images; //图片集合
 for(Image image : images){
  //处理图片
  improcessor.excutor(iamgeRunnable,image);
 }
  //等图片处理完成后终止线程
 imageProcessor.shutdown();
复制代码

(2)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不只是新的不允许,就算是正在执行的任务也不允许再继续执行。

:raising_hand:‍♀️辟谣: 网上有一个说法是:创建的线程池大小,取决于CPU的核数。 比如,你CPU有8个核,就创建8个线程,每个线程分配给你一个核,这样想想很有道理。但是其实是没道理的!你有8个核,你就占了所有的核了吗?不是这样的。不过,你的线程数跟你的CPU挂钩是有道理,它可以让你的软件在不同机器上表现相对一致。 所以,你的线程数跟你的CPU挂钩有道理,但是线程数=CPU核数就没道理了。大家记住了吧。

二、线程同步

线程同步主要包括以下内容:

多线程同步原理

2.1 JVM内存模型

在说明synchronized为什么能保证线程安全之前,我们先简单过一下JVM内存模型。

多线程同步原理

Java的内存模型有以下特点:

  • Java所有变量都存储在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本
  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接在主内存读写
  • 不同线程之间无法访问其他线程内存中的变量,线程间变量值的传递需要通过主内存来完成。线程1对共享变量的修改,要想被线程2及时看到,必须经过如下2个过程:

(1)把工作内存1中更新过的共享变量刷新到主内存中 (2)将主内存中最新的共享变量的值更新到工作内存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 先于前两行代码执行,此时计算结果就会出错;
复制代码

当然,重排序的内容不是本文重点,有兴趣的读者自行百度。

三、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();
  	 }
    }
  } 
}
复制代码

四、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变量的步骤为:

  1. 改变线程工作内存中volatile变量副本的值
  2. 将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量的步骤为:

  1. 从主内存读取volatile变量的最新值到线程的工作内存中
  2. 从工作内存中读取volatile变量的副本

由于在整个过程没有涉及到锁相关的操作,所以无法保证原子性,但是由于实时刷新了主内存中的变量值,因此任何时刻,不同线程总能看到该变量的最新值,保证了可见性。

下面出个练习来练练手: 有下面:point_down:这么一句代码:

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变量,必须同时满足:

  • 对变量的吸入操作不依赖其当前值
    • 不满足举例: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操作是原子的。

五、读写锁

请大家先思考以下问题: 对于一个公共变量,如果:

  1. 同时两个线程都在写,会出问题吗?
  2. 当一个线程在对变量进行写的时候,有另外一个线程想读呢?会出问题吗 ?
  3. 当一个线程在对变量进行读的时候,有另外一个线程写呢?会出问题吗?
  4. 当一个线程在对变量进行读的时候,有另外一个线程也准备读呢?会出问题吗?

答案是: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() {
    }
}
复制代码

六、Atomic包

这个包里的类本身就被设计成原子的,可以方便我们实现线程安全。 比如:

int count ;
//如果你想保证count++是安全的,但是不想用synchronized,那么使用AtomicInteger;
复制代码

七、面试题

好了,到这里本篇文章就已经结束了。在这次的文章中,我们主要简单介绍了线程和进程,详细了解了synchronized和volatile的工作原理,并对他们两者的使用场景进行了比较。相信你对多线程应该已经稍微熟悉一点了,现在来几道面试练练手,加深印象吧~Java线程面试题Top50

对于面试题里面的notify,wait,sleep等线程通信知识不了解的童鞋,欢迎期待下一场chat~

参考文章

  • 内存可见性和原子性
  • 细说Java多线程之内存可见
  • Java:手把手教你全面学习synchroinzed关键字
原文  https://juejin.im/post/5d27f27151882543b7222cf9
正文到此结束
Loading...