java并发编程专题(三)----详解线程的同步

有兴趣的朋友可以回顾一下前两篇

java并发编程专题(一)----线程基础知识

java并发编程专题(二)----如何创建并运行java线程

在现实开发中,我们或多或少的都经历过这样的情景:某一个变量被多个用户并发式的访问并修改,如何保证该变量在并发过程中对每一个用户的正确性呢?今天我们来聊聊线程同步的概念。

一般来说,程序并行化是为了获得更高的执行效率,但前提是,高效率不能以牺牲正确性为代价。如果程序并行化后, 连基本的执行结果的正确性都无法保证, 那么并行程序本身也就没有任何意义了。因此, 线程安全就是并行程序的根本和根基。解决这些问题从临界区的概念开始。临界区是访问一个共享资源在同一时间不能被超过一个线程执行的代码块。

java为我们提供了同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区,它使用其中的一个同步机制来找出是否有任何其他线程执行临界区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界 区,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。临界区有如下的规则:

  1. 如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入。
  2. 任何时候,处于临界区内的进程不可多于一个。如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待。
  3. 进入临界区的进程要在有限时间内退出,以便其它进程能及时进入自己的临界区。
  4. 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。

java语言为解决同步问题帮我们提供了两种机制来实现:

1. synchronized关键字;
2.  Lock锁及其实现;

synchronized的作用

关键字synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次, 只能有一个线程进入同步块,从而保证线程间的安全性。

关键宇synchronized 可以有多种用法。这里做一个简单的整理。

· 指定加锁对象: 对给定对象加锁,进入同步代码前要获得给定对象的锁。
· 直接作用于实例方法: 相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
. 直接作用于静态方法: 相当于对当前类加锁, 进入同步代码前要获得当前类的锁。

1.给指定对象加锁:

  public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync() ;
    static int i =O;
    @Override
    public void run() (
    for(int j=O; j<lOOOOOOO; j++) {
      synchronized (instance) {  //对象锁
        i++ ;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException (
    Thread t1=new Thread(instance);
    Thread t2=new Thread(instance);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
  }

  /*

  public static void main(String[] args) throws InterruptedException (
    Thread t1=new Thread(new AccountingSync());
    Thread t2=new Thread(new AccountingSync());
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
  }

  */

知道我为什么要给出两个main方法让大家参考吗?上述锁对象是锁定AccountingSync实例对象。第一个main方法中t1 和 t2 两个线程同时指向了instance实例,所以第7行的锁对象synchronized (instance)在线程t1 和 线程 t2 获得锁的时候是获取同一个对象的,这个时候的锁是同一把锁。但是在第二个main方法中我们可以看到线程t1 和 线程 t2分别对应的是两个不同的AccountingSync对象,这时候锁对象获得的是不同的AccountingSync实例,安全性是没有保证的,大家可以动手尝试一下。

2.直接作用于实例方法:

  public class TestSynchronized {
    public static void main(String[] args) {
      Tester2 a1 = new Tester2();
      Th t1 = new Th(a1);
      t1.start();
      Th t2 = new Th(a1);
      t2.start();
    }

  }
  class Tester2 {
    public synchronized void say(String name) throws InterruptedException{
      for(int i = 0;i<5;i++){
        Thread.sleep(1000);
        System.out.println();
        System.out.println(name +","+i+new Date().toLocaleString() );
      }
    }
  }
  class Th extends Thread{
    Tester2 test;
    public Th(Tester2 test1){
      test = test1;
    }
    public void run(){
      try {
        test.say(Thread.currentThread().getName());
      } catch (InterruptedException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
      }
    }
  }

对Tester2类中的方法使用synchronized很好理解,同一时刻如果t1正在调用say()方法,在他没有执行完毕并退出方法之前其余的线程是无法获得该方法的。只能排队等待知道t1执行完毕。

3.作用于静态方法:

 public class Test1 {
    public static void main(String[] args) {
      for(int i=0;i<50;i++){
        Thread t1 = new Thread(new Sale(5));
        Thread t2 = new Thread(new Producted(5));
        t1.start();
        t2.start();
      }
    }
  }

  class Shop{
    static int a = 40;
    synchronized static void shopping(int b){
      a -= b;
      System.out.println("售出 "+b+" 张大饼,"+"还剩 "+a+" 张大饼");
    }

    synchronized static void factory(int c){
      a += c;
      System.out.println("仓库还有 "+a+" 张大饼");
    }
  }

  class Sale implements Runnable{
    int b = 0;
    public Sale(int b){
      this.b = b;
    }

    @Override
    public void run() {
      if(b<0){
        Thread.interrupted();
      }
      Shop.shopping(b);
      try {
        Thread.sleep(1000);
        Shop.factory(b-5);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }

  class Producted implements Runnable{
    int b = 0;
    public Producted(int b){
      this.b = b;
    }

    @Override
    public void run() {
      Shop.factory(b);
      try {
        Thread.sleep(1000);
        Shop.shopping(b-5);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }

静态方法前加synchronized这个锁等价于锁住了当前类的class对象,因为静态方法或者是静态关键字在本质上是一个类对象,而不是成员对象,在内存中位于方法区被所有的实例共享。即等同于synchronized(Shop.class)。我们需要注意的是锁住了类并不代表锁住了类所在的对象,类本身也是一种对象。它与类的实例是完全不同的两个对象,在加锁时不是相互依赖的,即对类加锁并不与上面例子中的加锁互斥,锁住了子类或子类的对象与锁住父类或父类的对象是不相关的。

synchronized的使用其实主要是前面两种,对象锁和方法锁,静态方法锁我们并不常用到。其余的操作方式都是在这两种的基础上演变而来,比如大家经常说的“块级锁”:

  synchronized(object){
    //代码内容
  }

锁住的其实并不是代码块,而是object这个对象,所以如果在其他的代码中
也发生synchronized(object)时就会发生互斥。我们为什么要研究这些呢,因为如果我们不知道我们锁住的是什么,就不清楚锁住了多大范围的内容,自然就不知道是否锁住了想要得到互斥的效果,同时也不知道如何去优化锁的使用。

因此java中的synchronized就真正能做到临界区的效果,在临界区内多个线程的操作绝对是串行的,这一点java绝对可以保证。同时synchronized造成的开销也是很大的,我们如果无法掌握好他的粒度控制,就会导致频繁的锁征用,进入悲观锁状态。

volatile—-轻量级的synchronized

既然我们说到了synchronized那就不得不提到volatile,在java中synchronized是控制并发的,我们知道在我们对一个变量执行赋值操作的时候比如:i++,在执行完毕之后i的结果其实是写到缓存中的它并没有及时的写入到内存,后续在某些情况下(比如cpu缓存不够)再将cpu缓存写入内存,假设A线程正在执行i++操作,而此时B线程也来执行。B在执行i++之前是不会自己跑到缓存中去取变量的值的,它只会去内存中读取i,很显然i的值是没有被更新的,为了防止这种情况出现,volatile应运而生。

Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

我们来看一个例子:

  public class TestWithoutVolatile {
    private static boolean bChanged; 

    public static void main(String[] args) throws InterruptedException {
      new Thread() {
        @Override
        public void run() {
          for (;;) {
            if (bChanged == !bChanged) {
              System.out.println("!=");
              System.exit(0);
            }
          }
        }
      }.start();
      Thread.sleep(1);
      new Thread() {
        @Override
        public void run() {
          for (;;) {
            bChanged = !bChanged;
          }
        }
      }.start();
     }
  }

在上例中我们如果多次运行会出现两种结果,一种是正常打印:”!=”,还有一种就是程序会陷入死循环。但是我们如果给bChanged前面加上volatile的话则每次都会打印出”!=”,请读者朋友们下去可以尝试。
在此处没有加volatile之前之所以会出现有时可以出现正确结果有时则卡死的原因就在于两个线程同时在运行的过程中双方都在操作bChanged变量,但是该变量的值对于同时在使用它的另一个线程来说并不总是可见的,运气好的时候线程修改完值之后就写入主存,运气不好的时候线程只在缓存中更新了值并未写入主存。但是在加了volatile修饰之后效果则不同,因为volatile可以保证变量的可见性。
说到可见性,我们来看一幅图:

每一个线程都有相应的工作内存,工作内存中有一份主内存变量的副本,线程对变量的操作都在工作内存中进行(避免再次访问主内存,提高性能),不同线程不能访问彼此的工作内存,而通过将操作后的值刷新到主内存来进行彼此的交互,这就会带来一个变量值对其他线程的可见性问题。当一个任务在工作内存中变量值进行改变,其他任务对此是不可见的,导致每一个线程都有一份不同的变量副本。而volatile恰恰可以解决这个可见性的问题,当变量被volatile修饰,如private volatile int stateFlag = 0; 它将直接通过主内存中被读取或者写入,线程从主内存中加载的值将是最新的。

但是volatile的使用有着严格的限制,当对变量的操作依赖于以前值(如i++),或者其值被其他字段的值约束,这个时候volatile是无法实现线程安全的。被volatile修饰的变量必须独立于程序的其他状态。因为volatile只是保证了变量的可见性,并不能保证操作的原子性,所谓原子性,即有“不可分”的意思,如对基本数据类型(java中排除long和double)的赋值操作a=6,如返回操作return a,这些操作都不会被线程调度器中断,同一时刻只有一个线程对它进行操作。
看以下代码:

public class Counter {
    public volatile static int count = 0;
    public static void inc() {

      //这里延迟1毫秒,使得结果明显
      try {
        Thread.sleep(1);
      } catch (InterruptedException e) {

      }
      count++;
    }

    public static void main(String[] args) {
      //同时启动1000个线程,去进行i++计算,看看实际结果
      for (int i = 0; i < 1000; i++) {
        new Thread(new Runnable() {
          @Override
          public void run() {
            Counter.inc();
          }
        }).start();
      }
      //这里每次运行的值都有可能不同,可能为1000
      System.out.println("运行结果:Counter.count=" + Counter.count);
    }
  }

运行上面的例子我们可以发现每次运行的结果都不一样,预期结果应该是1000,尽管counter被volatile修饰,保证了可见性,但是counter++并不是一个原子性操作,它被拆分为读取和写入两部分操作,我们需要用synchronized修饰:

  publicstaticsynchronizedvoid incNum() {
    counter++;
  }

此时每次运行结果都是1000,实现了线程安全。synchronized是一种独占锁,它对一段操作或内存进行加锁,当线程要操作被synchronized修饰的内存或操作时,必须首先获得锁才能进行后续操作;但是在同一时刻只能有一个线程获得相同的一把锁,所以它只允许一个线程进行操作。synchronized同样能够将变量最新值刷新到主内存,当一个变量只被synchronized方法操作时,是没有必要用volatile修饰的,所以我们接着把变量声明修改为:

  private static int counter;

多次运行结果依旧是1000。

说明:

上例中如果你按照上面这样改完之后其实结果并是不1000,我多次运行的结果都是先打印出”运行结果:Counter.count=0”,然后线程卡死。究其原因,我猜可能是第一个线程等待一秒再执行count++,然后后面的线程在这个等待过程中等不及的原因。java线程的运行具有不确定性,不能保证线程会按部就班的顺序执行,所以会出现什么样的后果很难预测。
正确结果代码如下:

public class Counter {
    public static int count = 0;
    public synchronized static void inc() {
      count++;
    }

    public static void main(String[] args) {
      //同时启动1000个线程,去进行i++计算,看看实际结果
      for (int i = 0; i < 1000; i++) {
        new Thread(new Runnable() {
          @Override
          public void run() {
            Counter.inc();
          }
        }).start();
      }
      //这里每次运行的值都有可能不同,可能为1000
      System.out.println("运行结果:Counter.count=" + Counter.count);
    }
  }

综上所述,由于volatile只能保证变量对多个线程的可见性,但不能保证原子性,它的同步机制是比较脆弱的,它在使用过程中有着诸多限制,对使用者也有更高的要求,相对而言,synchronized锁机制是比较安全的同步机制,有时候出于提高性能的考虑,可以利用volatile对synchronized进行代替和优化,但前提是你必须充分理解其使用场景和涵义。

下一节我们接着分析Lock锁。

以上就是java并发编程专题(三)----详解线程的同步的详细内容,更多关于JAVA 线程同步的资料请关注我们其它相关文章!

(0)

相关推荐

  • java并发编程专题(十)----(JUC原子类)基本类型详解

    这一节我们先来看一下基本类型: AtomicInteger, AtomicLong, AtomicBoolean.AtomicInteger和AtomicLong的使用方法差不多,AtomicBoolean因为比较简单所以方法比前两个都少,那我们这节主要挑AtomicLong来说,会使用一个,其余的大同小异. 1.原子操作与一般操作异同 我们在说原子操作之前为了有个对比为什么需要这些原子类而不是普通的基本数据类型就能满足我们的使用要求,那就不得不提原子操作不同的地方. 当你在操作一个普通变量时,

  • java并发编程专题(五)----详解(JUC)ReentrantLock

    上一节我们了解了Lock接口的一些简单的说明,知道Lock锁的常用形式,那么这节我们正式开始进入JUC锁(java.util.concurrent包下的锁,简称JUC锁).下面我们来看一下Lock最常用的实现类ReentrantLock. 1.ReentrantLock简介 由单词意思我们可以知道这是可重入的意思.那么可重入对于锁而言到底意味着什么呢?简单来说,它有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放.这模仿了 sy

  • java并发编程专题(四)----浅谈(JUC)Lock锁

    首先我们来回忆一下上一节讲过的synchronized关键字,该关键字用于给代码段或方法加锁,使得某一时刻它修饰的方法或代码段只能被一个线程访问.那么试想,当我们遇到这样的情况:当synchronized修饰的方法或代码段因为某种原因(IO异常或是sleep方法)被阻塞了,但是锁有没有被释放,那么其他线程除了等待以外什么事都做不了.当我们遇到这种情况该怎么办呢?我们今天讲到的Lock锁将有机会为此行使他的职责. 1.为什么需要Lock synchronized 是Java 语言层面的,是内置的关

  • java并发编程专题(六)----浅析(JUC)Semaphore

    半路开始看的朋友可以回顾一下前几篇 java并发编程专题(一)----线程基础知识 java并发编程专题(二)----如何创建并运行java线程 java并发编程专题(三)----详解线程的同步 java并发编程专题(四)----浅谈(JUC)Lock锁 java并发编程专题(五)----详解(JUC)ReentrantLock Semaphore,从字面意义上我们知道他是信号量的意思.在java中,一个计数信号量维护了一个许可集.Semaphore 只对可用许可的号码进行计数,并采取相应的行动

  • java并发编程专题(七)----(JUC)ReadWriteLock的用法

    前面我们已经分析过JUC包里面的Lock锁,ReentrantLock锁和semaphore信号量机制.Lock锁实现了比synchronized更灵活的锁机制,Reentrantlock是Lock的实现类,是一种可重入锁,都是每次只有一次线程对资源进行处理:semaphore实现了多个线程同时对一个资源的访问:今天我们要讲的ReadWriteLock锁将实现另外一种很重要的功能:读写分离锁. 假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁.在没有写操作的时候,两个线

  • java并发编程专题(一)----线程基础知识

    在任何的生产环境中我们都不可逃避并发这个问题,多线程作为并发问题的技术支持让我们不得不去了解.这一块知识就像一个大蛋糕一样等着我们去分享,抱着学习的心态,记录下自己对并发的认识. 1.线程的状态: 线程状态图: 1.新建状态(New):新创建了一个线程对象. 2.就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法.该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权. 3.运行状态(Running):就绪状态的线程获取了CPU,执行程序代码. 4

  • java并发编程专题(九)----(JUC)浅析CyclicBarrier

    上一篇我们介绍了CountDownlatch,我们知道CountDownlatch是"在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待",即CountDownLatch的作用是允许1或N个线程等待其他线程完成执行,而我们今天要介绍的CyclicBarrier则是允许N个线程相互等待. 1.CyclicBarrier简介 CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier).它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)

  • java并发编程专题(十一)----(JUC原子类)数组类型详解

    上一节我们介绍过三个基本类型的原子类,这次我们来看一下数组类型: AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray.其中前两个的使用方式差不多,AtomicReferenceArray因为他的参数为引用数组,所以跟前两个的使用方式有所不同. 1.AtomicLongArray介绍 对于AtomicLongArray, AtomicIntegerArray我们还是只介绍一个,另一个使用方式大同小异. 我们先来看看AtomicLong

  • java并发编程专题(二)----如何创建并运行java线程

    实现线程的两种方式 上一节我们了解了关于线程的一些基本知识,下面我们正式进入多线程的实现环节.实现线程常用的有两种方式,一种是继承Thread类,一种是实现Runnable接口.当然还有第三种方式,那就是通过线程池来生成线程,后面我们还会学习,一步一个脚印打好基础. Runnable接口: public interface Runnable { public abstract void run(); } Thread类: public class Thread implements Runnab

  • java并发编程专题(八)----(JUC)实例讲解CountDownLatch

    CountDownLatch 是一个非常实用的多线程控制工具类." Count Down " 在英文中意为倒计数, Latch 为门问的意思.如果翻译成为倒计数门阀, 我想大家都会觉得不知所云吧! 因此,这里简单地称之为倒计数器.在这里, 门问的含义是:把门锁起来,不让里面的线程跑出来.因此,这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束, 再开始执行. CountDown Latch 的构造函数接收一个整数作为参数,即当前这个计数器的计数个数. public Co

随机推荐