有没有在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内存模型又有很多相同之处。
计算机在执行程序的时候,每条指令都是在CPU中执行的,而在CPU执行指令的过程中会涉及到数据的读取和写入操作,而在计算机运行过程中所有的数据都是存放在主存中的(比如一台普通的4C8G机器,这个8G就是指主存的容量),CPU则是从主存中读取数据进行运行。
由于CPU执行速度非常快,比计算机主存的读取和写入的速度快了很多,这样就会导致CPU的执行速度大大下降。
因此,每个CPU都会自带一个高速缓冲区,在运行的时候,会将需要运行的数据从计算机主存先复制到CPU的高速缓冲区中,然后CPU再基于高速缓冲区的数据进行运算,运算结束之后,再将高速缓冲区的数据刷新到主存中。这样CPU的执行指令的速度就可以大大提升。
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可重入锁。
善用局部变量可以避免出现线程安全问题,因为局部变量仅仅存在于每个线程的工作内存中。
public void test(){
int i = 0;
i++;
System.out.println(i);
}
只有当每个线程都执行到int i =0时,会在各自线程栈中创建该变量。
不可变对象是指一经创建,则对外的状态就不会改变的对象。如果一个对象的状态不变,无落多少个线程,对其如何操作,都不改变。例如字符串对象就是不可变对象。String s = "a",指字面值a是不可变的,而引用s可以变。
ThreadLocal本质是在每个线程有自己的一个副本,每个线程的副本互不影响。
一个命令为“I”的ThreadLocal类,他会在每个线程都有一个Integer对象,虽然每个线程都会在主内存把Integer对象拷贝到工作内存,但是拷贝的不是一个对象。
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循环直到成功。
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获取成功,则执行代码。
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/mingyuli/article/details/120375046
内容来源于网络,如有侵权,请联系作者删除!