Java多线程数据同步

水煮Java 专栏收录该内容
27 篇文章 1 订阅

当多个线程同时读写同一个份数据时其结果可能会和我们期望的结果不一致,例如两个线程对同一个变量做自增,得到的结果和我们想要的可能不一样,示例如下:

package com.shaoshuidashi;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Explore {
    int a = 0;
    void add(){
        a++;
    }
    public static void main(String[] args) throws InterruptedException{
        Explore e = new Explore();
        e.test();
    }
    public void test()
    {
        ExecutorService service = Executors.newCachedThreadPool();
        Runnable r = new Runnable() {
            public void run(){
                for (int i = 0; i < 1000000; i++) {
                    add();
                }
                System.out.println(a);
            }
        };
        service.execute(r);
        service.execute(r);
    }
}
/*输出(每次可能不一样)
Thread[pool-1-thread-1,5,main] finished a = 1011775
Thread[pool-1-thread-2,5,main] finished a = 1011775
 */

上例中两个线程同时对变量a进行自增操作,每个线程自增1000000次,我们期望得到的最终结果是a=2000000,运行程序得到的结果却是a=1011775。

为什么会出现这种情况呢?当两个线程同时执行a++时,假设此时a=0,那么第一个线程执行0++,第二个线程也是执行0++,其结果是a=1,比我们期望的值2小。两个线程同时执行a++这种情况的出现将导致程序执行结果比期望的结果小。因为我的机器是多核处理器所以同时执行a++操作是正常的现象。要解决这个问题需要使用线程间数据同步访问技术。

线程间数据同步访问

线程间数据同步访问是指把对数据的并发访问改为串行访问,如上一个例子中两个线程同时执行a++操作就是对变量a的并行访问,而串行访问则是让一个线程t1先访问变量a,此时另外一个线程t2将处于等待状态直到t1线程访问结束。有了同步技术就不会出现多个线程同时访问一个变量的情况。

有三种方法可以实现数据同步访问

  • 使用synchronized代码块
  • 使用synchronized关键字来修饰方法声明
  • 使用锁

synchronized

使用synchronized代码块的示例如下:

void add(){
    //......
    synchronized (this) {
        a++;
    }
    //......
}

上例中synchronized后面括号里面的内容表示需要同步访问的对象,本例中是this,紧接着的大括号里面的代码块表示访问代码也可以称作同步代码。如果多个线程同时调用同一个对象的add方法,那么synchronized代码块将被同步执行(串行执行)。这里要重点强调‘’同一个对象“,因为上例中我们是对this执行同步访问,不同对象的this是不一样的,即多个线程同时调用不同对象的add方法,synchronized代码块不会同步执行。

使用synchronized关键字来修饰方法声明的示例如下:

synchronized void add(){
    //......
    a++;
    //......
}

它表示同步访问this对象,访问代码为整个方法体,它等效于:

void add(){
    synchronized (this) {
        //......
        a++;
        //......
    }
}

相比使用synchronized修饰方法声明,synchronzied代码块可以控制细粒度的代码(访问代码)。

除了使用synchronized关键字外,还可以使用锁来进行数据同步访问。锁具备synchronized所不具备的高级方法。当一个线程T1执行同步代码块时,如果另外一个线程T2也想执行同步代码,那么T2只能等到T1执行完后才有机会执行同步代码,如果我们不想让T2傻等而是执行一些别的代码该怎么实现呢?synchronized不能实现这种需求,但是锁可以实现这种需求。

下面是锁的基础使用示例:

package com.shaoshuidashi;
import java.util.concurrent.locks.ReentrantLock;
public class Explore {
    int a = 0;
    ReentrantLock lock = new ReentrantLock();
    void add(){
        lock.lock();
        a++;
        lock.unlock();
    }
}

锁使用Lock接口来表示,本文使用ReentrantLock来学习锁,它是Lock接口的一个实现。Lock有两个重要的方法:

void lock();
void unlock();

被lock和unlock方法所包围的代码块表示需要同步执行的代码也称为被锁住的代码,当多个线程同时访问被同一把锁锁住的代码块时,代码块将被串行执行(同步执行),注意是“被同一把锁”。

使用锁时需要特别注意unlock是否被正确的调用,因为在一个方法体里面有很多路径可以返回值,如果一个方法已经返回了但是unlock没有被调用,那么另外在等待执行同步代码的线程将永远不会执行。使用try和finally语法是一种确保unlock被调用的技巧:

void add(){
    lock.lock();
    try {
        a++;
        return;
    }finally {
        lock.unlock();
    }
}

把需要同步访问的代码放到try块里面,unlock方法放到finally块里面,这样当try块执行完以后finally语句总是会被执行即unlock总是会被执行。

回到本节开头,如何让一个线程不再傻等?使用锁的tryLock方法可以实现这种需求:

void add(){
    if(lock.tryLock()) {
        //doSomething
        //......
        lock.unlock();
    }else{
        //doSomething
        //......
    }
}

tryLock会尝试获取锁,如果获取成功返回true,否则返回false。获取到锁以后可以执行需要同步的代码,否则执行其它的代码。

重入

synchronized和ReentrantLock都支持重入,重入的意思是:当一个线程已经在执行同步代码时,同步代码里面可以调用另外的同步代码,前提是同步代码被同一把ReentrantLock锁控制或者synchronized同一个对象。synchronized重入示例如下:

package com.shaoshuidashi;
import java.util.concurrent.locks.ReentrantLock;

public class Explore {
    ReentrantLock lock = new ReentrantLock();
     void method1(){
         synchronized(this) {
             System.out.println("method1");
             method2();
         }
    }

     void method2(){
         synchronized(this) {
             System.out.println("method2");
         }
    }

    public static void main(String[] args) throws InterruptedException{
        Explore e = new Explore();
        e.method1();
    }
}
/*输出
method1
method2
 */

ReentrantLock锁重入示例如下:

package com.shaoshuidashi;
import java.util.concurrent.locks.ReentrantLock;

public class Explore {
    ReentrantLock lock = new ReentrantLock();
     void method1(){
         lock.lock();
         System.out.println("method1");
         method2();
         lock.unlock();
    }

     void method2(){
         lock.lock();
         System.out.println("method2");
         lock.unlock();
    }

    public static void main(String[] args) throws InterruptedException{
        Explore e = new Explore();
        e.method1();
    }
}
/*输出
method1
method2
 */

C/C++中只有递归锁才可以重入,非递归锁则不能重入如果重入将发生死锁,本文中介绍的几种同步机制和递归锁非常相似。

最后

一份数据被多个线程读写时我们需要考虑线程数据同步,如果没有数据同步,有些问题可能不会在开发阶段暴露出来,而是在生产环境下随机的出问题,问题非常难定位,所以在开发阶段需要做好数据同步访问。我们应该优先使用synchronized来实现数据同步,只有synchronized无法实现需求时才考虑使用锁,因为synchronized语法更简洁。synchronized和ReentrantLock都是可重入的。

 

【水煮Java】

  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

参与评论 您还未登录,请先 登录 后发表或查看评论
©️2022 CSDN 皮肤主题:技术黑板 设计师:CSDN官方博客 返回首页

打赏作者

烧水匠

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值