Java并发编程 专题
专题目录
您的位置:java > Java并发编程专题 > 多线程共享资源问题演示
多线程共享资源问题演示
作者:--    发布时间:2019-11-22



Image.png

Java同步代码块(synchronized block)和锁是用来避免多个线程共享资源产生竞争,导致运行结果与期望不符合的一种机制。同步代码块和锁是Java并发编程中最最核心的概念,也是最容易让读者迷惑的地方,一些有了一些开发经验的用户,可能对此也没有彻底的理解。在本章中,我们将会详细的讲解每一个知识点,以最简化的方式,帮助读者理解每一个概念,并介绍相关术语。

线程竞争案例演示

首先通过一个案例来演示多线程竞争共享资源会产生的问题。在以下代码中,我们使用两个线程,对于1个整形变量从各加五百万次。我们期望的结果是一千万,但结果是不是这样呢?

public class ThreadCompetitionDemo {
    static int count=0;
    public static void main(String[] args) throws InterruptedException {
        long start=System.currentTimeMillis();
        new Thread(){
            @Override
            public void run() {
                for (int i = 0; i <5000000 ; i++) {
                    count++;
                }
                System.out.println("自定义线程:计算完成...,耗时"+(System.currentTimeMillis()-start));
            }
        }.start();
        for (int i = 0; i <5000000 ; i++) {
            count++;
        }
        System.out.println("主线程:计算完成....,耗时"+(System.currentTimeMillis()-start));
        Thread.sleep(10000);
        System.out.println("count:"+count);
    }
}

上述案例的其中一次运行结果如下(每次运行结果可能不同):

主线程:计算完成....,耗时17
自定义线程:计算完成...,耗时23
count:6929386

可以看到的结果并不是我们期望中的一千万。原因在于,count++并不是一个原子操作。每次自增实际上是分为3个步骤:

  1. 获取count变量的当前值

  2. 将当前值加1

  3. 将加1后的值存储到count变量中

因为线程是并行执行的,因此这就可能出现问题。例如假设count变量当前值是0,主线程和自定义线程同时获取到这个值,主线程先完成自增的操作,将count变量的值设置为1。自定义线程随后完成自增的操作,因为自定义线程也是在0的基础上加1,然后将值赋值给count变量,最终导致实际上进行了两次自增操作,但实际上确只加了1。

Tips:

1)变量的自增操作次数越多,出现问题的可能性越大。读者可以尝试将自增操作都改为加10次,会发现大部分情况下结果都是正确的。

2)主线中在完成自增操作后,休眠,是防止另外一个线程没执行完。就打印出了结果。不过从结果来看,都是20毫秒左右两个线程都各自完成了500万次的自增操作,所以休眠时间设置为10秒可能略长了。

3)在大部分教程中演示这个案例的时候整形变量都会采取long型,注意本文使用的int型,使用long变量是因为这个变量类型出现问题的可能性会更大,在后面的部分中,我们会进行详细讲解。

案例代码解决方案

我们可以通过synchronized关键字和来解决上述代码中出现的问题(关于这两个概念我们之后会深入的解释)。先来看一下修改后的代码:

public class ThreadCompetitionDemo {
    static int count=0;
    public static void main(String[] args) throws InterruptedException {
        long start=System.currentTimeMillis();
        new Thread(){
            @Override
            public void run() {
                for (int i = 0; i <5000000 ; i++) {
                    synchronized (ThreadCompetitionDemo.class){
                        count++;
                    }
                }
                System.out.println("自定义线程:计算完成...,耗时"+(System.currentTimeMillis()-start));
            }
        }.start();
        for (int i = 0; i <5000000 ; i++) {
            synchronized (ThreadCompetitionDemo.class){
                count++;
            }
        }
        System.out.println("主线程:计算完成....,耗时"+(System.currentTimeMillis()-start));
        Thread.sleep(10000);
        System.out.println("count:"+count);
    }
}

上述案例实际上仅仅将count++操作放到一个同步代码块(synchronized关键字,ThreadCompetitionDemo.class可以认为是一个锁)。运行结果如下:

主线程:计算完成....,耗时2151

自定义线程:计算完成...,耗时2166

count:10000000

可以看到结果是计算正确的,不管运行多少次,结果总是这样。关于为什么我们会在后面解释。这里要注意的是,我们每次线程完成五百万次累加操作的时间,都在2秒左右,而之前大概只需要20毫秒左右,花费的时间接近100倍。这说明同步代码块虽然可以帮助我们将结果计算正确,但是在性能上却有非常大的影响。

静态条件与临界区

在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件。实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要资源没有发生变化,多个线程读取相同的资源就是安全的。

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。例如上面的代码中,我们的count++操作,就是一个临界区,它会产生竞态条件。在临界区中使用适当的同步就可以避免竞态条件。 

Java中的同步块用synchronized标记。同步块在Java中是同步在某个对象上,事实上在java中,任何一个对象都可以作为一个锁。所有同步在一个对象上的同步块在同时只能被一个线程进入并执行操作。所有其他等待进入该同步块的线程将被阻塞,直到执行该同步块中的线程退出。

有四种不同的同步块:

    1. 实例方法

2. 静态方法

3. 实例方法中的同步块

4. 静态方法中的同步块

上述同步块都同步在不同对象上。实际需要那种同步块视具体情况而定。

实例方法同步

下面是一个同步的实例方法:

public synchronized void add(int value){
	this.count += value;
 }

注意在方法声明中同步(synchronized )关键字。这告诉Java该方法是同步的。

Java实例方法同步是同步在拥有该方法的对象上。这样,每个实例其方法同步都同步在不同的对象上,即该方法所属的实例。只有一个线程能够在实例方法同步块中运行。如果有多个实例存在,那么一个线程一次可以在一个实例同步块中执行操作。一个实例一个线程。

注意:多个线程要运行的是同一个对象实例的同步方法,如果一个每个线程运行的是不同的对象实例的同步方法,是没有同步效果的,因为每个对象实例是把自身当成锁,就导致没有公用一个锁。

静态方法同步

静态方法同步和实例方法同步方法一样,也使用synchronized 关键字。Java静态方法同步如下示例:

public static synchronized void add( int value ){
       count += value ;
     }

同样,这里synchronized 关键字告诉Java这个方法是同步的。

静态方法的同步是指同步在该方法所在的类对象上。因为在Java虚拟机中一个类只能对应一个类对象,所以同时只允许一个线程执行同一个类中的静态同步方法。

对于不同类中的静态同步方法,一个线程可以执行每个类中的静态同步方法而无需等待。不管类中的那个静态同步方法被调用,一个类只能由一个线程同时执行。

实例方法中的同步块

有时你不需要同步整个方法,而是同步方法中的一部分。Java可以对方法的一部分进行同步。

在非同步的Java方法中的同步块的例子如下所示:

public void add(int value){
    synchronized(this){
       this.count += value;
    }
  }


示例使用Java同步块构造器来标记一块代码是同步的。该代码在执行时和同步方法一样。

注意Java同步块构造器用括号将对象括起来。在上例中,使用了“this”,即为调用add方法的实例本身。在同步构造器中用括号括起来的对象叫做监视器对象。上述代码使用监视器对象同步,同步实例方法使用调用方法本身的实例作为监视器对象。

一次只有一个线程能够在同步于同一个监视器对象的Java方法内执行。

下面两个例子都同步他们所调用的实例对象上,因此他们在同步的执行效果上是等效的。

public class MyClass {

   public synchronized void log1(String msg1, String msg2){
      log.writeln(msg1);
      log.writeln(msg2);
   }

   public void log2(String msg1, String msg2){
      synchronized(this){
         log.writeln(msg1);
         log.writeln(msg2);
      }
   }
 }

在上例中,每次只有一个线程能够在两个同步块中任意一个方法内执行。

如果第二个同步块不是同步在this实例对象上,那么两个方法可以被线程同时执行。

静态方法中的同步块

和上面类似,下面是两个静态方法同步的例子。这些方法同步在该方法所属的类对象上。

public class MyClass {
    public static synchronized void log1(String msg1, String msg2){
       log.writeln(msg1);
       log.writeln(msg2);
    }

    public static void log2(String msg1, String msg2){
       synchronized(MyClass.class){
          log.writeln(msg1);
          log.writeln(msg2);
       }
    }
  }

这两个方法不允许同时被线程访问。

如果第二个同步块不是同步在MyClass.class这个对象上。那么这两个方法可以同时被线程访问。

Java同步实例--对象锁

在下面例子中,启动了两个线程,都调用Counter类同一个实例的add方法。因为同步在该方法所属的实例上,所以同时只能有一个线程访问该方法。

// 计数器
class Counter{
     long count = 0;

     public synchronized void add(long value){
       this.count += value;
       System. out.println(Thread. currentThread().getName()+":"+ count);
     }
  }
//计数线程  
class CounterThread extends Thread{

     protected Counter counter = null;

     public CounterThread(Counter counter){
        this.counter = counter;
     }

     public void run() {
	for(int i=0; i<10; i++){
           counter.add(1);
        }
     }
  }
//案例  
  public class Example {

    public static void main(String[] args){
      Counter counter = new Counter();
      Thread  threadA = new CounterThread(counter);
      Thread  threadB = new CounterThread(counter);

      threadA.start();
      threadB.start();
    }
  }

创建了两个线程。他们的构造器引用同一个Counter实例。Counter.add方法是同步在实例上,是因为add 方法是实例方法并且被标记上synchronized关键字。因此每次只允许一个线程调用该方法。另外一个线程必须要等到第一个线程退出add()方法 时,才能继续执行方法。

程序输出:

Thread-0:1
Thread-1:2
Thread-1:3
Thread-1:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
Thread-1:10
Thread-1:11
Thread-0:12
Thread-0:13
Thread-0:14
Thread-0:15
Thread-0:16
Thread-0:17
Thread-0:18
Thread-0:19
Thread-0:20

如果两个线程引用了两个不同的Counter实例,那么他们可以同时调用add()方法。这些方法调用了不同的对象,因此这些方法也就同步在不同的对象上。这些方法调用将不会被阻塞。如下面这个例子所示:

public class Example {

   public static void main(String[] args){
     Counter counterA = new Counter();
     Counter counterB = new Counter();
     Thread  threadA = new CounterThread(counterA);
     Thread  threadB = new CounterThread(counterB);

     threadA.start();
     threadB.start();
   }
 }

注意这两个线程,threadA和threadB,不再引用同一个counter实例。CounterA和counterB的add方法同步在他们所属的对象上。调用counterA的add方法将不会阻塞调用counterB的add方法。

输出:

Thread-0:1
Thread-1:1
Thread-0:2
Thread-1:2
Thread-0:3
Thread-0:4
Thread-0:5
Thread-0:6
Thread-0:7
Thread-0:8
Thread-0:9
Thread-0:10
Thread-1:3
Thread-1:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
Thread-1:10

在有多个实例的情况下,如果我们依然要实现之前的结果,可以将Counter的代码改一下--类锁

 class Counter{
    static long count = 0;

    public static synchronized   void add( long value ){
      count += value;
      System. out.println(Thread. currentThread().getName()+":"+ count);
    }
 }

输出:

Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-0:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
Thread-1:10
Thread-1:11
Thread-1:12
Thread-1:13
Thread-1:14
Thread-1:15
Thread-0:16
Thread-0:17
Thread-0:18
Thread-0:19
Thread-0:20


网站声明:
本站部分内容来自网络,如您发现本站内容
侵害到您的利益,请联系本站管理员处理。
联系站长
373515719@qq.com
关于本站:
编程参考手册