Java多线程简介及线程同步的本质原理


前言

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

目录

enter image description here

1 多线程基础

1.1 进程与线程

enter image description here

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

用生活中的场景来比喻的话呢,就是假设你住在一个小区,这个小区就是一个操作系统,你家就是一个进程,你家的柴米油盐是不跟其他户人家共享的,为什么?因为你们互相之间没关系。这个柴米油盐就是资源。

线程就是你们这个家的人,你们互相之间同时运行,可以同时干自己的事情。

1.2 线程创建的方式

线程创建的方式主要包括:

enter image description here

/**
     * 使用 Thread 类来定义工作
     */
    static void thread() {
        Thread thread = new Thread() {
            @Override
            public void run() {
                System.out.println("Thread started!");
            }
        };
        thread.start();
    }
/**
     * 使用 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 是有返回值的 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();
        }

JDK 1.5 后引入的 Executor 框架的最大优点是把任务的提交和执行解耦。通过 Executors 的工具类可以创建以下类型的线程池:

enter image description here

下面介绍常用的两种线程池。

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);
}

Executor 接口里面有两个重要的方法,一个是 shutdown,一个是 shutdownNow。

它们两个的区别是:shutdown 不再允许扔新的 runnable 进来。shutdownNow 不只是新的不允许,就算是正在执行的任务也不允许再继续执行。

✋辟谣:

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

所以,你的线程数跟你的 CPU 挂钩有道理,但是线程数 = CPU 核数就没道理了。大家记住了吧。

2 线程同步

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

enter image description here

2.1 JVM 内存模型

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

enter image description here

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

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 的工作流程是:

  1. 获取互斥锁,清空工作内存中的共享变量的值
  2. 在主内存中拷贝最新变量的副本到工作内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存中
  5. 释放互斥锁

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

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

线程读 volatile 变量的步骤为:

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

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

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

private volatile int number =0;

问:当创建 500 个线程同时操作 number++ 时,是否能保证最终打印的值是 500?

答案:不能,因为 number++ 不是原子操作,而 volatile 无法保证原子性。

那要如何改呢?

解法1synchronized关键字
synchronized(this){
number++;
}
解法2:使用ReentrankLock
private ReentrankLock lock = new ReentrankLock();
lock.lock();
try{
number++;
}finally{
lock.unlock();
}
解法3 int改成AtomicIntege

4.3 volatile 适用场合

要在多线程中安全的使用 volatile 变量,必须同时满足:

在实际项目中,由于很多情况下都不满意 volatile 的使用条件,所以 volatile 使用的场景并没有 synchronized 广。

4.4 synchronized 和 volatile 比较

enter image description here

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 会出问题,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 的工作原理,并对他们两者的使用场景进行了比较。

相信你对多线程应该已经稍微熟悉一点了,现在来几道面试练练手,加深印象吧~

Java 线程面试题 Top50

参考文章


  #Thread 

« 装饰器模式