Java多线程及线程同步原理

进程和线程

操作系统中运行多个软件,一个运行中的软件可能包含多个进程,一个运行中的进程可能包含多个线程。

  • 进程 : 提到线程时,不得不提到进程。进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以被看作程序的实体,同样,它也是线程的容器。进程就是程序的实体,是受操作系统管理的基本运行单元。比如在Android中,一个应用程序就是一个进程。

  • 线程 : 线程是按代码顺序执行下来,执行完毕就结束的一条线。是操作系统调度的最小单元,也叫作轻量级进程。在一个进程中可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。

    • CPU线程
      • 多核 CPU 的每个核各自独立运行,因此每个核一个线程
      • 「四核八线程」:CPU 硬件方在硬件级别对 CPU 进行了一核多线程的支持(本质上
        依然是每个核一个线程)
    • 操作系统线程:操作系统利用时间分片的方式,把 CPU 的运行拆分给多条运行逻辑,即为
      操作系统的线程
    • 单核 CPU 也可以运行多线程操作系统
    • UI 线程为什什么不不会结束?因为它在初始化完毕后会执行死循环,循环的内容是刷新界面

    为什么要使用多线程

    • 使用多线程可以减少程序的响应时间。如果某个操作很耗时,或者陷入长时间的等待,此时程序将不会响应鼠标和键盘等的操作,使用多线程后可以把这个耗时的线程分配到一个单独的线程中去执行,从而使程序具备了更好的交互性。
    • 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高。
    • 多CPU或者多核计算机本身就具备执行多线程的能力。如果使用单个进程,将无法重复利用计算机资源,这会造成资源的巨大浪费。在多CPU计算机中使用多线程能提高CPU的利用率。
    • 使用多线程能简化程序的结构,使程序便于理解和维护。
线程的状态

Java线程在运行的声明周期中可能会处于6种不同的状态

  • New:新创建状态。线程被创建,还没有调用 start 方法,在线程运行之前还有一些基础工作要做。
  • Runnable:可运行状态。一旦调用start方法,线程就处于Runnable状态。一个可运行的线程可能 正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。
  • Blocked:阻塞状态。表示线程被锁阻塞,它暂时不活动。
  • Waiting:等待状态。线程暂时不活动,并且不运行任何代码,这消耗最少的资源,直到线程调度器

​ 重新激活它。

  • Timed waiting:超时等待状态。和等待状态不同的是,它是可以在指定的时间自行返回的。
  • Terminated:终止状态。表示当前线程已经执行完毕。导致线程终止有两种情况:第一种就是run方法执行完毕正常退出;第二种就是因为一个没有捕获的异常而终止了run方法,导致线程进入终止状态。
多线程的实现
  • 继承Thread类,重写run() 方法。Thread本质上也是实现了Runnable接口的一个实例,调用start() 方法后并不是立即地执行多线程的代码,而是使该线程变为可运行态,什么时候运行多线程代码是由操作系统决定的。

    Thread thread = new Thread() {
      @Override
      public void run() {
          System.out.println("Thread started!");
      }
    };
    thread.start();
    
  • 定义类实现Runnable接口,并实现该接口的run() 方法。创建Thread子类的实例,用实现Runnable接口的对象作为参数实例化该Thread对象。调用Thread的start() 方法来启动该线程。

    Runnable runnable = new Runnable() {
      @Override
      public void run() {
          System.out.println("Thread with Runnable started!");
      }
    };
    Thread thread = new Thread(runnable);
    thread.start();
    
  • 实现Callable接口,重写call() 方法。Callable接口实际是属于Executor框架中的功能类,Callable接口与Runnable接口的功能类似,但提供了比Runnable更强大的功能:

    (1) Callable可以在任务接受后提供一个返回值

    (2) Callable中的call() 方法可以抛出异常,而Runnable的run() 方法不能抛出异常

    (3) 运行Callable可以拿到一个Future对象,Future对象表示异步计算的结果,它提供了检查计算是否
    完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下就可以使用 Future 来监视目标线程调用 call() 方法的情况。但调用 Future的get() 方法以获取结果时,当前线程就会阻塞,直到call() 方法返回结果

    Callable<String> callable = new Callable<String>() {
      @Override
      public String call() {
        System.out.println("callable call");
        return "Done!";
      }
    };
    
    ExecutorService executor = Executors.newCachedThreadPool();
    Future<String> future = executor.submit(callable);
    try {
      String result = future.get();
      System.out.println("result: " + result);
    } catch (InterruptedException | ExecutionException e) {
      e.printStackTrace();
    }
    
  • ThreadFactory

    ThreadFactory factory = new ThreadFactory(){
                int count = 0;
                @Override
                public Thread newThread(Runnable r) {
                    count++;
                    return new Thread(r,"Thread--" + count);
                }
            };
    
    Runnable runnable = new Runnable(){
      @Override
      public void run() {
          System.out.println(Thread.currentThread().getName() + "started!");
      }
    };
    
    Thread thread1 = factory.newThread(runnable);
    thread1.start();
    Thread thread2 = factory.newThread(runnable);
    thread2.start();
    
  • Executor 和线程池

线程同步与线程安全

在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况通常被称为竞争条件。

例如:车站售卖火车票,火车票是一定的,但卖火车票的窗口到处都有,每个窗口就相当于一个线程。这么多的线程共用所有的火车票资源,如果不使用同步是无法保证其原子性的。在一个时间点上,两个线程同时使用火车票资源,那其取出的火车票是一样的(座位号一样),这样就会给乘客造成麻烦。解决方法:当一个线程要使用火车票这个资源时,我们就交给它一把锁,等它把事情做完后再把锁给另一个要用这个资源的线程。这样就不会出现上述情况了。

实现线程安全的几种方式

  • synchronized

    synchronized 关键字自动提供了锁以及相关的条件。大多数需要显式锁的情况使用synchronized非常方便

    • synchronized 方法 : Java中的每一个对象都有一个内部锁。如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。要调用该方法,线程必须获得内部的对象锁。

      private synchronized void count(int newValue) {
        x = newValue;
        y = newValue;
        if (x != y) {
          System.out.println("x: " + x + ", y:" + y);
        }
      }
      
    • synchronized 代码块 :

      private void count(int newValue) {
        synchronized (this) {
          x = newValue;
          y = newValue;
          if (x != y) {
              System.out.println("x: " + x + ", y:" + y);
          }
        }
      }
      

    synchronized 的本质 : 保证方法内部或代码块内部资源(数据)的互斥访问。即同一时间、由同一个
    Monitor 监视的代码,最多只能有一个线程在访问 ; 保证线程之间对监视资源的数据同步。即,任何线程在获取到 Monitor 后的第一时间,会先将共享内存中的数据复制到自己的缓存中;任何线程在释放 Monitor 的第一时间,会先将缓存中的数据复制到共享内存中。

  • ReentrantLock

    重入锁ReentrantLock是Java SE 5.0引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必要的, 保证在方法提前结束或出现 Exception 的时候,依然能正常释放锁。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。

    Lock lock = new ReentrantLock();
    ...
    lock.lock();
    try {
    x++;
    } finally {
    lock.unlock();
    }
    

    一般并不不会只是使用 Lock ,而是会使用更复杂的锁,例如 ReadWriteLock :

    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    Lock readLock = lock.readLock();
    Lock writeLock = lock.writeLock();
    private int x = 0;
    private void count() {
      writeLock.lock();
      try {
          x++;
      } finally {
          writeLock.unlock();
      }
    }
    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();
      }
    }
    
  • volatile

    有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大;而volatile关键字为实例域的
    同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。通过内存模型的相关概念以及并发编程中的3个特性:原子性、可见性和有序性来了解volatile

    Java内存模型

    Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在内存可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。Java 内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。本地内存是Java内存模型的一个抽象概念,其并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

    线程A与线程B之间要通信的话,首先需要线程A把线程A本地内存中更新过的共享变量刷新到主存中去,然后线程B到主存中去读取线程A之前已更新过的共享变量。

    原子性、可见性和有序性

    • 原子性

      对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕,要么就不执行。

      x = 3;  //语句1   原子操作
      y = x;  //语句2   非原子操作
      x++;    //语句3   非原子操作
      

      语句1是原子性操作。语句2虽说很短,但它包含了两个操作,它先读取x的值,再将x的值写入工作内存。读取x的值以及将x的值写入工作内存这两个操作单拿出来都是原子性操作,但是合起来就不是原子性操作了。语句3包括3个操作:读取x的值、对x的值进行加1、向工作内存写入新值。所以,一个语句含有多个操作时,就不是原子性操作,只有简单地读取和赋值(将数字赋值给某个变量)才是原子性操作。java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如 AtomicInteger 类提供了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数自增和自减。可以安全地使用AtomicInteger类作为共享计数器而无须同步。另外这个包还包含AtomicBoolean、AtomicLong和AtomicReference这些原子类。

    • 可见性

      可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其他线程是可见的。当有其他线程需要读取该值时,其他线程会去主存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。

    • 有序性

      Java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序过程不会影响到单线程执行的正确性,但是会影响到多线程并发执行的正确性。这时可以通过volatile来保证有序性,除了volatile,也可以通过synchronized和Lock来保证有序性。synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于是让线程顺序执行同步代码,从而保证了有序性。

    当一个共享变量被volatile修饰之后,其就具备了两个含义,一个是线程修改了变量的值时,变量的新值对其他线程是立即可见的。就是不同线程对这个变量进行操作时具有可见性。另一个含义是禁止使用指令重排序。重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编
    译时和运行时环境。

    • volatile保证可见性

      如下:线程1先执行,线程2后执行。开发时中断线程时可能会采用这种方式。但是这段代码不一定会将线程中断。虽说无法中断线程这个情况出现的概率很小,但是一旦发生这种情况就会造成死循环。每个线程在运行时都有私有的工作内存,因此线程1在运行时会将stop变量的值复制一份放在私有的工作内存中。当线程2更改了stop变量的值之后,线程2突然需要去做其他的操作,这时就无法将更改的stop变量写入主存当中,这样线程1就不会知道线程2对stop变量进行了更改,因此线程1就会一直循环下去。当stop用volatile修饰之后,那么情况就变得不同了,当线程2进行修改时,会强制将修改的值立即写入主存,并且会导致线程1的工作内存中变量stop的缓存行无效,这样线程1再次读取变量stop的值时就会去主存读取。

      //线程1
      boolean stop = false;
      while (!stop){
      //dosomething
      }
      
      //线程2
      stop = true;
      
    • volatile保证有序性

      volatile关键字能禁止指令重排序,因此volatile能保证有序性。volatile关键字禁止指令重排序有两个含义:一个是当程序执行到volatile变量的操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还没有进行;在进行指令优化时,在volatile变量之前的语句不能在volatile变量后面执行;同样,在volatile变量之后的语句也不能在volatile变量前面执行。

    • volatile不保证原子性

      public class MainTest {
      
          public volatile int i = 0;
      
          public void increase(){
              i++;
          }
      
          public static void main(String[] args){
              MainTest mainTest = new MainTest();
              for (int i = 0; i < 20; i++) {
                  new Thread(){
                      @Override
                      public void run() {
                          for (int j = 0; j < 1000; j++) {
                              mainTest.increase();
                          }
                      }
                  }.start();
              }
      
              //如果有子线程就让出资源,保证所有子线程都执行完
              while (Thread.activeCount() > 2){
                  Thread.yield();
              }
              System.out.println(i);
          }
      }
      

      这段代码每次运行,结果都不一致。自增操作是不具备原子性的,它包括读取变量的原始值、进行加 1、写入工作内存。也就是说,自增操作的 3 个子操作可能会分割开执行。假如某个时
      刻变量inc的值为9,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了。之后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,然后进行加1操作,并把10写入工作内存,最后写入主存。随后线程1接着进行加1操作,因为线程1在此前已经读取了inc的值为9,所以不会再去主存读取最新的数值,线程1对inc进行加1操作后inc的值为10,然后将10写入工作内存,最后写入主存。两个线程分别对inc进行了一次自增操作后,inc的值只增加了1,因此自增操作不是原子性操作,volatile也无法保证对变量的操作是原子性的。

    如何正确使用volatile关键字

    synchronized关键字可防止多个线程同时执行一段代码,那么这就会很影响程序执行效率。而volatile关键字在某些情况下的性能要优于synchronized。但是volatile关键字是无法替synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备两个条件:

    • 对变量的写操作不会依赖于当前值(也就是不能自增自减)
  • 该变量没有包含在具有其他变量的不变式中

    public volatile int lower, upper ;
    
        public int getLower(){
            return lower;
        }
    
        public int getUpper(){
            return upper;
        }
    
        public void setLower(int value){
            if (value > upper){
                throw new IllegalArgumentException();
            }
            lower = value;
        }
        
        public void setUpper(int value){
            if (value < lower){
                throw new IllegalArgumentException();
            }
            upper = value;
        }
    }
    

    这种方式将lower和upper字段定义为volatile类型不能够充分实现类的线程安全。如果当两个线程在同一时间使用不一致的值执行setLower和setUpper的话,则会使范围处于不一致的状态。例如,如果初始状态是(0,5),在同一时间内,线程A调用setLower(4)并且线程B调用setUpper(3),虽然这两个操作交叉存入的值是不符合条件的,但是这两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4,3),这个结果是错误的,没有保证原子性。

使用volatile的场景

  • 状态标志

    public volatile boolean shutDown ;
    
    public void shutDown(){
        shutDown = true;
    }
    
    public void doWork(){
        while (!shutDown){
          //dosomething
        }
    }
    

    如果在另一个线程中调用 shutdown 方法,就需要执行某种同步来确保正确实现shutDown变量
    的可见性。但是,使用synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。在这里状态标志shutDown并不依赖于程序内的任何其他状态,并且还能简化代码。因此,此处适合使用volatile。

  • 双重检查模式(DCL)

    public class Singleton{
        public static Singleton instance;
    
        private void Singleton (){}
    
        public static Singleton getInstance(){
            if (instance == null){
                synchronized (this){
                    if (instance == null){
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    

    getInstance方法中对Singleton进行了两次判空,第一次是为了不必要的同步,第二次是只有在Singleton等于null的情况下才创建实例。在这里用到了volatile关键字会或多或少地影响性能,但考虑到程序的正确性,牺牲这点性能还是值得的。DCL的优点是资源利用率高,第一次执行getInstance方法时单例对象才被实例化,效率高。其缺点是第一次加载时反应稍慢一些,在高并发环境下也有一定的缺陷(虽然发生的概率很小)。

volatile总结:与锁相比,volatile变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循volatile的使用条件,即变量真正独立于其他变量和自己以前的值,在某些情况下可以使用volatile代替synchronized来简化代码。然而,使用volatile的代码往往比使用锁的代码更加容易出错。所以需要根据实际情况来选择使用 volatile还是使用synchronized。

总结

线程安全问题的本质

在多个线程访问共同的资源时,在某一个线程对资源进行写操作的中途(写入已经开始,但还没
结束),其他线程对这个写了一半的资源进行了读操作,或者基于这个写了一半的资源进行了写
操作,导致出现数据错误。

锁机制的本质

通过对共享资源进行访问限制,让同一时间只有一个线程可以访问资源,保证了数据的准确性。不论是线程安全问题,还是针对线程安全问题所衍生出的锁机制,它们的核心都在于共享的资源,而不是某个方法或者某几行代码。

Java中锁的分类可以看这篇文章:https://www.cnblogs.com/qifengshi/p/6831055.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,783评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,396评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,834评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,036评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,035评论 5 362
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,242评论 1 278
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,727评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,376评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,508评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,415评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,463评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,140评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,734评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,809评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,028评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,521评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,119评论 2 341

推荐阅读更多精彩内容