Java并发基础(一):多个线程读写同一共享变量是否存在并发问题?

x33g5p2x  于2021-09-19 转载在 Java  
字(4.5k)|赞(0)|评价(0)|浏览(557)

一、经典面试题

有没有在JAVA笔试或面试中遇见过这样的题目:统计服务器某个接口的访问次数。

public class AccountCount {

    int accessCount;

    public void access(){
        accessCount ++;
    }
}

问题:多线程调用access()方法时,接口访问次数统计的结果是否能保证准确呢?

自定义线程中的实例变量针对其他线程有共享和不共享之分。

1.1、多个线程共享同一个变量,产生线程安全问题。

public class AccountCount extends Thread{
    int accessCount;

    public void access(){
        accessCount ++;
        System.out.println(Thread.currentThread().getName() + accessCount);
    }

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

    public int getAccount() {
        return accessCount;
    }

    public static void main(String[] args) {

        AccountCount accountCount = new AccountCount();
        new Thread(accountCount,"A").start();
        new Thread(accountCount,"B").start();
        new Thread(accountCount,"C").start();
        new Thread(accountCount,"D").start();
        new Thread(accountCount,"E").start();
        new Thread(accountCount,"F").start();
    }

}
B2
E5
F6
A6
D6
C4

2)、不共享数据时每个线程都拥有自己作用域的变量,且多个线程之间相同变量名的值也不相同

public class AccountCount extends Thread{
    int accessCount;

    public void access(){
        accessCount ++;
        System.out.println(Thread.currentThread().getName() + accessCount);
    }

    public AccountCount(String accessCount) {
        this.setName(accessCount);
    }

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

    public int getAccount() {
        return accessCount;
    }

    public static void main(String[] args) throws InterruptedException {
        new AccountCount("A").start();
        new AccountCount("B").start();
        new AccountCount("C").start();
        new AccountCount("D").start();
        new AccountCount("E").start();
        new AccountCount("F").start();
    }
}
A1
B1
E1
D1
F1
C1

二、内存模型概念

        我们的程序是运行在Java虚拟机上面的,Java虚拟机本身有自己的内存模型, Java的内存模型和计算机的CPU内存模型又有很多相同之处。

2.1、CPU内存模型

        计算机在执行程序的时候,每条指令都是在CPU中执行的,而在CPU执行指令的过程中会涉及到数据的读取和写入操作,而在计算机运行过程中所有的数据都是存放在主存中的(比如一台普通的4C8G机器,这个8G就是指主存的容量),CPU则是从主存中读取数据进行运行。

        由于CPU执行速度非常快,比计算机主存的读取和写入的速度快了很多,这样就会导致CPU的执行速度大大下降。

        因此,每个CPU都会自带一个高速缓冲区,在运行的时候,会将需要运行的数据从计算机主存先复制到CPU的高速缓冲区中,然后CPU再基于高速缓冲区的数据进行运算,运算结束之后,再将高速缓冲区的数据刷新到主存中。这样CPU的执行指令的速度就可以大大提升。

2.2、JVM内存模型

        JVM启动之后,操作系统会为JVM进程分配一定的内存空间,这部分内存空间就称为“主内存”。另外Java程序的所有工作都由线程来完成,而每个线程都会有一小块内存,称为“工作内存”即线程栈。 Java中的线程在执行的过程中,会先将数据从主内存(指堆内存)中复制到线程的工作内存,然后再执行计算,执行计算之后,再把计算结果刷新到“主内存”中。

三、基于内存模型一步一步分析缓存一致性问题

        假设现在有两个线程同时访问了这个接口的access()方法,两个线程都执行了accessCount++,在内存中是怎么样执行的呢?

首先我们要明白,计算机需要执行accessCount++ 这个语句,需要分为以下3个步骤:

从主存中读取accessCount的值
1.
将accessCount的值进行加1
1.
将accessCount的值写回主存中

先来看看第1步,假设两个线程同时来执行accessCount()方法

上面图中在第1个步骤的时候,线程1和线程2都会把accessCount的值从主存中复制到线程所属的工作内存中,两个线程此时得到的accessCount的值都是0。

接着两个线程执行第2步操作:将accessCount的值进行加1:

图中,线程1和线程2都进行了第2步的计算,然后线程1得到的结果是 accessCount=1,线程2得到的结果也是accessCount=1。

接着两个线程都到了第三步:将accessCount的值写回主存中:

线程1和线程2都计算完之后就会将计算结果刷新回主存,特别注意一下图中红框的内容,这是两个线程把计算结果刷新回主存的步骤,两个红框中操作的执行顺序不分先后(在实际运行情况,两个操作的顺序是随机的,可能是线程1先刷新,也可能是线程2先刷新),但是这不影响结果,因为无论是线程1还是线程2,写回主存的结果都是accessCount=1。

但是实际上,我们观察到是2个线程都执行了一次access()方法,按照预期来说accessCount的值应该是等于2才对。

这种多个线程访问同一个对象时,调用这个对象的方法得到不正确的结果,这种问题称为线程安全问题。

四、解决并发问题的方法有哪些 

解决并发问题方法分为两类:无锁和有锁。

无锁分为:局部变量、不可变对象、ThreadLocal、CAS原子类。

有锁分为:synchronized关键字 和 reentranLock可重入锁。

4.1、局部变量

善用局部变量可以避免出现线程安全问题,因为局部变量仅仅存在于每个线程的工作内存中。

public void test(){
    int i = 0;
    i++;
    System.out.println(i);
}

只有当每个线程都执行到int i =0时,会在各自线程栈中创建该变量。

4.2、不可变对象

不可变对象是指一经创建,则对外的状态就不会改变的对象。如果一个对象的状态不变,无落多少个线程,对其如何操作,都不改变。例如字符串对象就是不可变对象。String s = "a",指字面值a是不可变的,而引用s可以变。

4.3、ThreadLocal

ThreadLocal本质是在每个线程有自己的一个副本,每个线程的副本互不影响。

 一个命令为“I”的ThreadLocal类,他会在每个线程都有一个Integer对象,虽然每个线程都会在主内存把Integer对象拷贝到工作内存,但是拷贝的不是一个对象。

4.4、CAS原子类

CAS(Compare And Swap)比较交换。

对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS比较与交换的伪代码可以表示为:

do{

备份旧数据;

基于旧数据构造新数据;

}while(!CAS( 内存地址,备份的旧数据,新数据 ))

 其中内存值V是56,旧的预期值CPU1和CPU2中的值,要修改的新值为最新值。

        在Java中,以Atomic为前缀的一系列类都采用CAS思想。

Atomic使用的是无锁的CAS操作,基于乐观锁,并发性能比较高,可以多个线程同时执行,保证线程安全。

Atomic简单使用:首先声明一个AtomicInteger的成员变量,然后再atomicAdd()方法中调用incrementAndGet()。

private AtomicInteger counter = new AtomicInteger(0);

    public void actomicAdd(){
        counter.incrementAndGet();
    }

atomicInterger源码分析:内部有一个Unsafe实例,Unsafe类提供硬件级别的原子操作,因为Java无法直接访问到操作系统底层的硬件,故Java使用native方法进行扩展,其中Unsafe类就是一个操作入口。Unsafe提供几种功能:CAS操作、内存的分配和释放、挂起和恢复线程、定位对象字段内存地址、修改对象的字段值等。

其中对于Unsafe类的CAS的操作,主要调用了unsafe的getAndInt()方法:

 首先通过var5获取旧值,然后调用compareAndSwapInt()通过CAS操作对数据比较交换,如果操作失败进行while循环直到成功。

4.5、Synchronized/ReentrantLock加锁

Synchronized和ReentrantLock都采用悲观锁策略。

Synchronized是语言层面

ReentrantLock是编程方式实现

public class ReentratLockTest {
    
    private int count = 0;
    private ReentrantLock reentrantLock = new ReentrantLock();
    
    public void lockMethod(){
        reentrantLock.lock();
        try {
            add();
        } finally {
            reentrantLock.unlock();
        }
    }
    
    public synchronized void lockMethod2(){
        add();
    }
    
    private void add(){
        count++;
    }
}

加锁原理:首先两个线程争抢同一把锁,假如线程1获取到锁,而线程二没获取锁就会进入等待队列,等到线程1执行完代码逻辑,会去通知线程2,此时线程2重新尝试获取锁,假如线程2获取成功,则执行代码。

 

相关文章