多线程并发编程2-基础2

并发与并行

    本文抛出的第一个问题就是何为并发?何为并行?并发,是指同一时间段多个任务同时都在执行,并都没有结束。经常有听到TPS/QPS这些指的就是每秒的并发的响应数/请求数。并行,是指在单位时间内多个任务同时在执行。

    在以前的单CPU时代多线程并发并没有什么意义,因为单个CPU同一个单位时间只能执行一个任务,其余为被执行的任务就需要被挂起,这就导致线程间频繁的上下文切换带来了额外开销。

线程安全问题

    谈到多线程编程就会涉及到多线程间的线程安全问题,线程安全问题首先要说一说共享资源。何为共享资源?就是多个线程可以去访问的资源。而多个线程去访问一个资源就会带来线程安全问题。例如:计数问题,计数分为这三步,1.获得原计数 2.原计数累加数 3.将累加后的值设置到计数上,由于这三步不是原子的,在多线程的环境下就会造成一个线程正在执行步骤1,而有其他的线程在执行步骤3,从而导致最终的计数不正常。想要解决上述问题,java中最常见的就是使用synchronized关键字进行同步,下面会介绍。

线程可见性问题

    说完线程安全问题,接着要说一说线程可见性问题。简单的说就是线程A操作完的值,在其他线程能不能获得线程A更新的值。关于可见性问题需要说说java的内存模型。

    上图是一个双核CPU的系统架构,每个核都有自己的控制器、运算器和一级缓存,而这CPU还有一个共享的二级缓存。而造成这个可见性问题就是由于这个一级缓存和二级缓存造成的。当一个线程操作一个共享变量的时候会先从一二级缓存里面找,而不是直接从主内存中找,如果这时一个线程将该共享变量修改并写回主内存,但是其他线程中的一二级缓存中存的却是该共享变量修改前的值。这就造成了不可见问题。可以使用volatile关键字进行解决不可见问题。下面用一个例子加深印象:

1)线程A首先获得共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0.然后把X=0缓存到两级缓存,线程A修改X的值为1,然后将其写入两级缓存,并刷新到主内存。线程A操作完后,线程A所在的CPU的两级Cache和主内存里的X的值都是1。

2)线程B获得X的值,首先一级缓存没有命中,然后二级缓存命中了,所以返回X=1;然后线程B修改X的值为2,并将其存放到线程2的两级缓存,最后更新主内存中的X为2。

3)线程A再次需要修改X的值,一级缓存命中,获得X=1,这时问题出现了,线程B已经将X的值修改为2,而线程A却获得到X=1,这就造成了不可见问题。

synchronized关键字

    synchronized关键字在上面提到了可以解决线程间安全问题,其实除了线程间安全性问题,它还可以解决内存可见性问题。下面对synchronized关键字进行说明。

    synchronized块是java提供的一种原子性内存锁,java的任何一个对象都可以把它当做一个同步锁来使用。当线程进入到synchronized块之前会自动获得监视器锁,而其他线程将想要获得同一个对象的监视器锁时会被阻塞挂起,当获得监视器锁的线程正常退出synchronized块或抛出异常或是在synchronized块内调用的wait方法释放锁,其他线程才有机会获得锁从而进入synchronized块内。

    synchronized关键字如何解释上面提到的计数器问题?

    计数器问题的核心就是一个线程在设置新增的时候另一个线程同时在获得计数器值。将计数器的三个步骤放到synchronized块内进行操作,当一个线程获得监视器锁并进入到synchronized块进进行计数器的三个步骤时,其他线程是无法进入到synchronized块内,那就不会造成读取的操作同时进行,从而解决了计数器的同步问题。

    synchronized是如何解决可见性问题的呢?

    那就要说说synchronized的内存语义了。进入synchronized块是把synchronized块内使用的变量从线程的工作内存中清除,在synchronized块内使用这些变量就需要从主内存中进行获取。而退出synchronized块的时候将块内使用的共享变量的修改刷新到主内存中,从而解决了可见性问题。

volatile关键字  

    上面提到了volatile关键字可以解决可见性问题,那它和如何解决的呢?

    在java中当一个变量被volatile关键字修饰的时候,线程在写入该变量时不会将值缓存到一二级缓存中,而是直接写会到主内存中。而线程读取该变量的时候也不会从一二级缓存中读取,而是直接从主内存中读取,从而解决了可见性问题。

    volatile虽然可以解决可见性问题,但是它不能保证原子性从而也就没办法解决线程间安全性问题。

CAS操作

    在java中虽然锁可以解决线程的安全问题和可见性问题,但是为获得锁的线程会被阻塞挂起,这就造成了线程上下文切换和重新调度开销,从而降低性能。现在要说的CAS(Compare and Swap)是一种非阻塞方式的原子操作,它通过硬件保证了比较-更新操作的原子性,至于硬件是如何保证的这不属于本文需要讨论的东西。

    CAS有一个经典的ABA问题,何为ABA问题?

    ABA问题就是线程1使用CAS修改初始值为A的变量X时,需要先获取X的当前值,在执行CAS前,线程2使用CAS修改了变量X的值为B,之后又使用CAS修改变量X的值为A,这个A已经不是初始的A,这就是ABA问题。

    造成ABA的问题是因为变量的状态值产生了环形转换,可以使用AtomicStampedReference来避免ABA问题。

Unsafe类

    这里介绍几个Unsafe类的重要方法,因为在之后juc中的类底层很多都是使用Unsafe类的方法进行实现的。

public native long objectFieldOffset(Field var1):返回指定的变量在所属类中的内存偏移地址。

public native int arrayBaseOffset(Class var1):获得数组中第一个元素的地址。

public native int arrayIndexScale(Class var1):获得数组中一个元素占用的字节。

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5):比较对象var1中偏移量为var2的变量(通过objectFieldOffset方法获得)的值是否与var4相等,相等则使用var5值更新,并返回true,否则返回false。

public native long getLongVolatile(Object var1, long var2):获得var1对象偏移量为var2的变量(通过objectFieldOffset方法获得)的值,支持volatile语义。

public native void putLongVolatile(Object var1, long var2, long var4):设置var1对象偏移量为var2的变量(通过objectFieldOffset方法获得)的值为var4,支持volatile语义。

public native void putOrderedLong(Object var1, long var2, long var4):设置var1对象偏移量为var2的变量(通过objectFieldOffset方法获得)的值为var4,不保证可见性,只有在保证var2变量使用volatile修饰才建议使用该方法。

public native void park(boolean var1, long var2):阻塞当前线程,var1为true表示使用绝对时间,var1位false则表示使用相对时间。

public native void unpark(Object var1):唤醒调用park后阻塞的线程。

public final long getAndSetLong(Object var1, long var2, long var4):获得对象var1中偏移量为var2的变量volatile语义的当前值,并设置变量volatile语义的值为var4.由于内部使用了自旋,在高并发情况下有可能会造成性能损耗。

    这有个问题就是想在自己的类中使用Unsafe类的方法,通过Unsafe.getUnsafe()获得实例,之后调用Unsafe方法时抛出SecurityException异常,如下图所示:

   造成这个问题的原因是因为Unsafe类是rt.jar包提供的,而rt.jar包中的类是使用Boostrap类加载器进行加载的,而我们使用java编写的类大多是使用AppClassLoader加载的,而在Unsafe.getUnsafe()获得实例的源码中会进行校验加载器,如下图所示:

    那么问题来了,到底要怎样才能使用Unsafe类呢?可以使用反射机制进行获取Unsafe类实例,如下代码所示:

//通过反射获得Unsafe的成员变量theUnsafe

Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

//设置为可存取

theUnsafe.setAccessible(true);

Unsafe unsafe = (Unsafe) theUnsafe.get(null);

伪共享

什么是伪共享?

    为了解决主内存与cpu之间运行速度差,会在cpu与主内存之间添加一级或多级高速缓冲存储器(CPU Cache)。而Cache内是按行进行存储的(Cache行),是Cache与主内存进行数据交换的最小单位,由于Cache行是内存块而不是单个变量,所有有可能多个变量存在同一个Cache行中,而一个Cache行只能同时有一个线程操作,所以性能会有所下降,这个就是伪内存。

注:地址连续的多个变量才有可能被放到一个Cache行,例如:数组。

    在单线程下访问一个缓存行里面的多个变量反而会对程序的性能进行提升。

如何解决伪共享?

    使用字节填充的方式避免,也就是创建一个变量时使用填充该变量所在的缓存行,从而避免将多个变量存在同一个缓存行中。

    jdk8之后可以使用注解@Contented来解决伪共享。该注解只能用于java核心类,想在用户类路径下使用该注解需要添加jvm参数:-XX:-RestrictContended。通过-XX:ContendedPaddingWidth指定填充宽度,默认128。

   今天的分享就到这,有看不明白的地方一定是我写的不够清楚,所有欢迎提任何问题以及改善方法。

发布了11 篇原创文章 · 获赞 3 · 访问量 598

猜你喜欢

转载自blog.csdn.net/zfs_sir/article/details/104863662