Java

多线程基础(三):synchronized关键字及java内存模

2020-09-06  本文已影响0人  冬天里的懒喵

[toc]

1.线程安全问题

在前面了解过一些java多线程基础之后,现在,我们用多线程来解决一个实际问题。
假定每个线程可以将一个数字加到100000,现在我们用十个线程,同时相加,看看结果是不是1000000?,代码如下:

ackage com.dhb.concurrent.test;

import java.util.concurrent.CountDownLatch;

public class SyncDemo implements Runnable{

    private static int count = 0;
    static CountDownLatch countDownLatch = new CountDownLatch(10);

    public static void main(String[] args) throws InterruptedException{
        for(int i=0;i<10; i++) {
            Thread t = new Thread(new SyncDemo());
            t.start();
        }
        countDownLatch.await();
        System.out.println(count);

    }

     private void add(){
        for (int i = 0; i < 100000; i++) {
            count++;
        }
        countDownLatch.countDown();
    }

    @Override
    public void run() {
        add();
    }
}

在上述代码中,分别启动了10个线程,现在看看输出结果是不是1000000?

361177

结果跟预想的并不一样,我们再执行一次看看?

294781

每次还不一样。
这就说明,一定遇到了线程安全的问题。即我们定义的count这个成员变量,在10个线程并发访问的过程中,可能出现了脏读,即一个线程还没有写入完成,另外一个线程就读到了这个没写完的结果,这样就导致了最终的结果不为1000000。为此,我们解决这个问题,不得不想到一个关键字,synchronized来解决。

2.synchronized 的使用说明

并发问题,通常需要解决两类问题,一个是互斥,即资源只能同时由一个线程来访问,当这个线程在访问的过程中,其他线程不能访问这个变量。这就是互斥。另外一个问题就是同步,同步主要是解决线程间通信的问题。即线程由于获取不到访问这个变量之前需要的锁资源,就会进入阻塞状态,让出CPU执行权限。那么何时能够重新执行呢,就需要访问资源的线程在执行完成之后进行通知,wait和notify方法就是很好的线程同步方法。
实际上synchronized的英文就是同步的意思,但是比较有意思的是,synchronized主要是解决的互斥问题。即加锁。
我们将代码修改为如下方式:

 private void add(){
    synchronized (SyncDemo.class) {
        for (int i = 0; i < 100000; i++) {
            count++;
        }
        countDownLatch.countDown();
    }
}

再次查看执行结果:

1000000

果然,输出的就是想要的结果了。但是,如果我们将sunchronized的代码块修改为如下呢?

 private void add(){
    synchronized (this) {
        for (int i = 0; i < 100000; i++) {
            count++;
        }
        countDownLatch.countDown();
    }
}

结果如下:

205975

又不能满足了。这说明,synchronized代码块,括号中锁定的对象,是有讲究的,前面的SyncDemo.class,由于SyncDemo.class是个特殊的对象,只有一个对象。因此多线程访问的时候就会形成互斥。而改成this之后,由于这个类在使用的时候通过new,导致了多个实例,实例与实例之间加索就不能构成互斥关系。
另外,上述代码块也可以与如下情况等价:

 private synchronized void add(){
    for (int i = 0; i < 100000; i++) {
        count++;
    }
    countDownLatch.countDown();
}

如果方法中除了代码块没有任何内容,那么这种方式与前面的synchronized(this)等价。
此外synchronized(SyncDemo.class)也与如下等价:

 private static synchronized void add(){
    for (int i = 0; i < 100000; i++) {
        count++;
    }
    countDownLatch.countDown();
}

对,就是将方法改为静态方法,这样锁住的就是类了。我们总结一下:

分类 详细分类 被锁的对象 代码示例
方法 实例方法 类的实例对象 public synchronized void method(){ ... ... }
方法 实例方法 类对象 public static synchronized void method() { ... ... }
代码块 实例对象 类的实例对象 synchronized(this) { ... ... }
代码块 class对象 类对象 synchronized(SyncDemo.class){ ... ... }
代码块 任意实例对象Object 实例对象Object Object lock = new Object(); synchronized(lock){ ... ... }

理论上来说,synchronized()的括号中可以是任意对象。但是,需要注意的是, 一般我们最好不要用String和包装类做为被锁定的对象。 这是因为,在jvm中,对这些类进行特殊处理,String类,尤其是G1中要是开启了字符串去重,那么全部jvm中都只有这一个对象。这样会导致许多系统其他的功能受到影响。包装类由于有常量池,也会导致同样的问题,这样你会莫名其妙的感觉系统卡顿。

3.java的内存模型JMM

在前面学习伪共享的时候了解过,操作系统中,实际上CPU与主内存之间存在多级缓存架构。而这些多级高速缓存的速度远远高于主内存的读取速度。其结构如下:


高速缓存模型

高速缓存和主内存以及CPU的同步关系,需要通过缓存一致性协议来确保数据的一致性。如MESI、MSI等协议。通过这些协议,才能保证各内存高速缓存与主内存的数据一致性。这个模型如下图所示


高速缓存模型

除了高速缓存之外,为了使CPU运算单元尽可能的充分利用,还会对输入的代码进行优化,其先后顺序会被改变。因此,在实际代码的执行过程中,其先后顺序不一定按照代码顺序来执行。这就是指令的重排序。关于这一点的细节再后续volatile关键字部分进行详细介绍。
那么JAVA实际上也是与这个模型类似,再java虚拟机中,虚拟机做为最外层的容器,其执行的逻辑与这个模型也非常相似。实际上,线程是CPU的最小执行单位,Java的内存模型实际上是对这个模型的抽象。在java中,也分为主内存和工作内存:

在java中,工作内存与主内存的交互,主要通过如下8种活动来进行,每个活动都是原子性的。

可以看到,上述图种绿色部分就是在工作内存种执行的活动。其他活动则是在主内存种执行。其过程详细如下图:


JMM过程

在每个线程中,其执行的时候的变量,实际上是其主内存中变量的副本。那么如果采用了synchronized,则会用lock操作锁定该变量,之后其他线程并无法访问。之后再进行read、load过程,之后使用或者赋值。
对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; volatile修饰的变量则除外。
上述这些操作,JSR133规定,需要满足如下规则:

4.总结

本文从线程安全问题引出了synchronized的用法。以及java内存模型的简单介绍。当然,synchronized还有可重入,以及底层具体实践和优化的知识也是非常重要的部分。后续对此详细介绍。

上一篇下一篇

猜你喜欢

热点阅读