提醒:本站内容仅供个人学习使用,不得用于任何商业用途。当前仅供站长徒弟学习使用!
关于多线程方面的学习,因学习方向不同,业界划分为两派:一派是招式派,另一派是内功派。

招式派侧重于招式的研究,追求规范的理解,侧重于形,但是略显教条,让人呆板;而内功派,侧重于思想的探寻,讲究灵性,侧重于神,但是在规范方面稍显不谨慎。招式派以并发网(www.ifeve.com)为最出名,而内功派则以本站唯马首是瞻。尤其要强调一点:本站的内容更侧重于思想性的分析,缺少细节知识的罗列,细节知识可参考互联网!

个人的视角总归有限,再者由于内容本身的特性,再怎么绞尽脑汁也难以写的多出彩,所以真诚欢迎有志于多线程技术的读者过来交流和切磋。

本站的名字是:新线程世界。之所以用"新"字冠名,希望本站的内容做到:择新成章,一针见血。

本系列内容最后更新日期是:2020年1月18日

作者简介

本系列内容由 MyBatis中文官网 站长 编写。

站长的做事理念是:专注和坚持。把一件小事长期做下去,小就能变大。

虽然网上写 多线程 的人很多,但是站长坚守这样的原则:人无我有,人有我精,人精我专,所以本系列内容有很多可圈可点的阅读价值!

站长是名技术爱好者,有多年的Web和大数据开发经验,主要代表作:

(1)《趣谈shell》,体现站长编码内功的作品。

(2)《一针见血 ThreadLocal》和《新线程世界》,体现站长在多线程与并发领域功底的作品。

(3)《秒杀内参》,体现站长架构设计的作品。

(4)还有 MyBatis中文官网,体现站长专注和坚持的作品。

(5)《认知数学》,体现站长在数学方面的功底。


喜欢技术的人,往往以技术为核心,并乐意分享和传播技术。

近年来,站长带徒和指导过的人有:南加州大学物理博士,某生物学博士,密歇根大学硕士,西安电子科技大学硕士,还有很多本科,专科等学生。

别人主动说一下,站长就记住了,很多人不说,站长也不会问的,因为无论出身怎样,无论学历高低,在知识面前大家都是平等的。

《新线程世界:一针见血多线程》
第一节:线程池到底该设置多少个线程呢?
第二节:一句话理解内存屏障/内存栅栏
第三节:CPU的乱序执行
第四节:亲切的 Volatile
第五节:内存可见性和寄存器可见性
第六节:无锁与自旋锁
第七节:yield,礼让一下,别当真。
第八节:发早餐过程中的双重校验锁
第九节:虚拟机、运行时、main线程
第十节:等待的艺术
第十一节:CAS入门简介
第十二节:无锁与自旋锁
第十三节:线程上下文
第十四节:纵观原子操作发展史
第十五节:多线程的平等
第十六节:数据的存储:缓存行和伪共享,工作内存和主内存
第十七节:名相近性相远:进程、线程、协程
第十八节:CopyOnWrite入门教程
第十九节:线程池的核心参数(别样角度,更有启发)
第二十节:单线程->多线程->消息中间件
第二十一节:线程的堵塞
第一节:线程池到底该设置多少个线程呢?

这个问题,如同一记闷棍,打得多少码农晕头转向。一腔怒火胸口燃烧,可恶的加班,让多少英雄年少活生生变成了curd boy。

线程池到底该设置多少个线程呢?这个问题,跟CPU核数有关。

在Java中,通过下面的代码,我们可以很容易地获取到系统可用的处理器核心数目:


Runtime.getRuntime().availableProcessors();

线程池的基本原则是:应用程序的最小线程数应该等于可用的处理器核数。

具体场景分为以下两种情况:

(1)如果所有的任务都是计算密集型的,则创建处理器可用核心数那么多个线程就可以了。

在这种情况下,创建更多的线程对程序性能而言,反而是不利的。因为当有多个任务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。

(2)如果任务都是IO密集型的,那么我们就需要开更多的线程来提高性能。

当一个任务执行IO操作时,其线程将被阻塞,于是处理器可以立即进行上下文切换以便处理其他就绪线程。如果我们只有处理器可用核心数那么多个线程的话,即使有待执行的任务也无法处理,因为我们已经拿不出更多的线程供处理器调度了。

总之,如果任务被阻塞的时间少于50%,即这些任务是计算密集型的,则程序所需线程数将随之减少,但最少也不应低于处理器的核心数。如果任务被阻塞的时间大于执行时间,即该任务是IO密集型的,我们就需要创建比处理器核心数大几倍数量的线程。例如,如果任务有50%的时间处于阻塞状态,则程序所需线程数为处理器可用核心数的两倍。

计算程序所需线程的总数的公式如下:

线程数=CPU可用核心数/(1-阻塞系数),其中阻塞系数的取值在0和1之间。

计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。

其实,一个完全阻塞的任务是注定要挂掉的,所以我们无须担心阻塞系数会达到1。

第二节:一句话理解内存屏障/内存栅栏

本节内容从简单入手,但是简单不代表肤浅。这个东西本来就很简单,所以写不出什么更多的内涵。

技术都是从简单发展起来的,然后变得复杂,到最后甚至都已经偏离了初衷。有的人瞧不起简单,可不是什么好事啊。

世界上本来没有路,走的人多了,也就变成了路。但是,很多路都是歧路,让人晕头转向,越往前走越是云山雾罩。

就拿内存屏障来说,一个句话就能搞明白的事情,但是网上的文章是汗牛充栋啊,抓住了细节,往往把主干丢了。本文希望去糟留精,只保留精华成分。

简单来说,指令如同上下班的人流和车流,人来人往,如下面图一般。内存屏障(Memory Barrier,或内存栅栏,Memory Fence)就像是红绿灯,它让一部分指令先行,而对另外一部分指令限行。



内存栅栏就像是马路上的红绿灯,在多线程并发过程中,仅当写操作线程先跨越内存栅栏,而读线程后跨越内存栅栏的情况下,写操作线程所做的变更才对其他线程可见。

备注:在程序运行过程中,所有的变更会先在寄存器中完成,然后才会被拷贝到主存以跨越内存栅栏,此种跨越序列的顺序称为happens-before。 happens-before本质是顺序,重点是跨越内存栅栏。 通常情况下,写操作必须要happens-before读操作,即写线程需要在所有读线程跨越内存栅栏之前完成自己的跨越动作,其所做的变更才能对其他线程可见。
第三节:CPU的乱序执行

每当上下班的高峰期,总是有少数人不顾社会公德,一窝蜂似的冲上公交车。在文明社会里面,讲究“有序排队,文明礼貌”,理应杜绝故意插队和无序拥挤。但是对CPU而言,指令的乱序执行是正常,不乱序才是有问题呢。这与我们的生活观念不符,令人难以接受,下面请听我把原因一一道来。

现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回等若干个阶段。多条指令可以同时存在于流水线中,同时被执行。指令流水线并不是串行的,并不会因为一个耗时很长的指令在执行阶段呆很长时间,而导致后续的指令都卡在执行之前的阶段上。

相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于执行阶段,而两条加法指令在执行阶段就只能串行工作。

相比于串行方式,流水线像这样并行的工作,效率是非常高的。可以体会下面的例子:


public class CPUDemo {
    static int mainmemory = 1;
    public static void main(String[] args) {
        int sum = mainmemory +1;
        int localmemory = 2;
    }
}

mainmemory变量是从主存中加载的,肯定不如localmemory赋值快,localmemory的赋值操作可是在寄存器中操作的,所以就发生了乱序:

int localmemory = 2;先执行完,然后再执行完int sum = mainmemory +1;

备注:本文从例子入手,仅仅是为了更形象的说明指令的乱序执行问题。至于底层到底是怎么执行的,执行过程中有哪些细节,本文未做考证,敬请注意。
第四节:亲切的 Volatile

我们善用synchronized,用的出神入化,花样繁多:

synchronized可用于修饰方法 ,代码如下所示:


public synchronized void method()
{
   // todo
}

synchronized还可用于修饰代码块,代码如下所示:


public void method()
{
   synchronized(this)
{
      // todo
    }
}

虽然我们能把synchronized用的出神入化,但是用volatile的机会很少。

synchronized代码块里面的变量都实现了内存可见性。volatile修饰的是变量,它的作用也是实现内存可见性。也许synchronized和volatile底层用的是同一个CPU指令。

其实换个角度想想,synchronized里面的变量完全可以看做被volatile修饰,这样一想,是不是感觉volatile离我们很近很亲切,不陌生了。虽然这种角度看问题,会有点意外的发生,但是极大的拉近了volatile与我们之间的距离,是弊大于利的。

volatile除了保证内存可见性,还有个作用是防止指令重排。例如,在Java虚拟机新建对象的过程中会出现指令重排,即:先分配内存空间,然后把内存空间首地址返回给变量,最后才进行对象实例化。加了volatile后会强制先进行实例化,最后才把对象地址返回被变量。

想想看,指令重排是不是最终的思想来源还是内存可见性呢?如果两个互不相关的思想,用到一个事物上,感觉怪怪的。

我后来想了想,寄存器和主存的隔离造成了数据的不一致,volatile的初衷是保证数据的强一致性。当赋值基本简单类型的时候,这种一致性很容易实现。但是赋值对象类型的时候,这种一致性分为强一致性和弱一致性,重排是弱一致性,而有序则是强一致性,volatile的目的是强一致性,所以最终它要求指令不得重排。从这个角度来说,可以把可见性和有序性都统一到一致性上面了,这就是为什么volatile同时具备内存可见性和防止指令重排两大功效的原因所在。

第五节:内存可见性和寄存器可见性

在计算机中,数据在哪里存放着呢?只在内存吗?

答:不是的。最初数据先在内存中存放,但是当用的时候会加载到CPU的寄存器里面。

内存和寄存器是两个地方,从而出现了新的名词:内存可见性和寄存器可见性。

为什么叫内存可见性呢?感觉这是一个很奇怪的名字。其实,明白以下道理就不奇怪了:

数据的流动过程是:内存->寄存器->运算器

很多时候,数据从内存地址读取到寄存器里面,在随后的计算过程中CPU就一直使用寄存器里面的值,即便内存里面的值发生变化,CPU也不知道,CPU此时就是井底之蛙,而变量此时可以称为:寄存器可见性。

但是当变量被volatile修饰之后,CPU就不敢再偷懒,只要用到数据,它都会越过寄存器,直接从内存中读取,然后再在运算器中计算,最后返回给内存,这个时候变量就不在寄存器里面停留,就当寄存器不存在一样,这就称为内存可见性。

第六节:无锁与自旋锁

1、无锁同步

无锁同步一般指的是CAS,CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。

2、无锁同步的优点

对于资源竞争较少的情况,使用同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗CPU资源。而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。

3、无锁同步的不足

对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于锁。CAS在判断两次读取的值不一样的时候会放弃操作,但为了保证结果正确,通常都会继续尝试循环再次发起CAS操作,如JDK1.6版本的AtomicInteger类的getAndIncrement()方法,就是利用了自旋实现多次尝试:


public final int getAndIncrement()
{
    for (;;)
    {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
        {
            return current;
        }
    }
}

如果compareAndSet(current, next)方法成功执行,则直接返回;如果线程竞争激烈,导致compareAndSet(current, next)方法一直不能成功执行,则会一直循环等待,直到耗尽cpu分配给该线程的时间片,从而大幅降低效率。

综之,

(1)使用CAS在线程冲突严重时,因为自旋会大幅降低程序性能;CAS只适合于线程冲突较少的情况使用。

(2)线程冲突严重的情况下,同步锁能实现线程堵塞和唤醒切换,不会出现自旋,避免了上述的情况,从而让性能远高于CAS。

4、无锁的延伸:自旋锁

其实,自旋锁没有那么高大上,就是一个死循环

我们有时会遇到这样的场景:线程A执行到某个点的时候,因为某个条件condition不满足,需要线程A暂停;等到线程B修改了条件condition,使condition满足了线程A的要求时,A再继续执行。最简单的实现方法就是将condition设为一个volatile的变量,当A线程检测到条件不满足时就自旋,类似下面这种情况:


public class Test
{
    private static volatile int condition = 0;
    public static void main(String[] args) throws InterruptedException
    {
        Thread worker = new Thread(new Runnable()
        {
            public void run()
            {
                while(condition != 1)
                {
                    // 条件不满足,自旋
                }
                System.out.println("a executed");
            }
        });
        worker.start();
        Thread.sleep(2000);
        condition = 1;
    }
}

这种方式的问题在于自旋非常耗费CPU资源,当然如果在自旋的代码块里加入Thread.sleep(time)将会减轻CPU资源的消耗,但是如果time设的太大,A线程就不能及时响应condition的变化,如果设的太小,依然会造成CPU的消耗。

第七节:yield,礼让一下,别当真。

早上,你去上班,路上遇见一个熟人,领着一袋苹果,从菜市场往家走,他会对你说:来,吃个苹果吧。这个时候,你千万别当真,他只是用手比划了比划,领苹果的手往前伸了伸而已,你压根就接不到递过来的苹果。

yield就是这样,它就是比划了比划而已,它未必真的让出CPU,所以可以在任意地方,写上任意多个yield,反正只是意思意思而已,CUP又不一定真的被让出去。如下面代码所示:


public class YieldDemo
{
 public static void main(String[] args)
 {
  System.out.println("I am working...");
  Thread.yield();
  Thread.yield();
  Thread.yield();
  Thread.yield();
  //可以在任意地方,写上任意多个yield
//反正只是意思意思而已,CUP又不一定真的被让出去。
 }
}
第八节:发早餐过程中的双重校验锁

公司团建,约定某日去某地旅游。某日到了,早上,大家一起去车站集合。在车站里,公司人事询问大家:都吃早餐了吗?没有吃的人站出来到旁边。这是第一重校验。

没有吃早餐的人,都站在了旁边。这是进入了发餐区,也算是隔离区。

公司几个人事开始给大家发早餐,发之前首先问一下:是否已经领到早餐了,如果已经领到早餐了的,就不发了,免得重复发。这是第二重校验。

整个代码的实现过程就是这样的:


public class Breakfast
{
	private volatile String bread = null;

	public String getBread()
	{
		if (bread == null)
		{
			synchronized (this)
			{
				if (bread == null)
				{
					bread = new String("cream");
				}
			}
		}
		return bread;
	}

}
第九节:虚拟机、运行时、main线程

1、Java虚拟机

Java虚拟机是一个想象中的机器,在实际的计算机上通过软件模拟来实现。Java虚拟机有自己想象中的硬件,如处理器、堆栈、寄存器等,还具有相应的指令系统。

2、Java运行时

Java运行时是指Java虚拟机在运行的状态。可以看做是:进程级别的虚拟机。

任何语言要运行都需要自己的运行时,Java 程序的运行时叫 Java Runtime,Android 程序的运行时叫 Android Runtime,而具体 Runtime 是个什么东西呢,就是说一个程序要在一个硬件或者平台上跑,就必须要有一个中间层用来把程序语言转换为机器能听懂的机器语言。

3、Java运行时(Java进程)->main线程

看下面的代码:


public class HelloWorld
{
	public static void main(String[] args)
	{
		System.out.println("Hello World!");
	}
}

执行的结果为:"Hello World!,但是整个程序的生命周期是什么样的呢?估计很少人能够说清楚吧。应该是这样的:

第一步:操作系统启动Java运行时,这是启动了Java进程。

第二步:Java运行时加载HelloWorld.class文件

第三步:Java运行时启动main线程,main线程仅仅是一个入口线程,它不是线程世界的老大,这一点已经强调过多次了,请务必牢记在心中。

4、JRE的发展历史:小程序的祖宗Applet

Java Runtime Environment,简称JRE。在 90 年代,微软的 IE 浏览器为了打败网景浏览器,曾经就在 IE 中默认安装 Java 运行时,再加上 IE 浏览器内置在 Windows 操作系统中,使得 IE 装机量特别大,这对 Java 也是一个非常大的促进作用。由于 IE 内置 Java Runtime,使得在 IE 浏览器中开发 Java 程序变得更加简单,当时最出名的小程序是:Java Applet(Java 小程序)。二十年后,最火的是微信小程序。

5、JRE和JDK的区别

在Java开发领域,很多人经常把 JRE 跟 JDK 搞混。

JRE 是一个独立的东西,就是 Java 程序的运行环境,其中包含一个 JAVA 虚拟机(JVM)以及一些标准的函数类库。

而 JDK 是 Sun 公司专门给开发人员准备的 Java 开发工具集。它其中就包含了 JRE,所以配置好 JDK,自然就可以在电脑上运行 Java 程序了。除此之外,JDK 还包含了源码、API 文档、编译工具等等。

另外,还有一个令大家困惑的问题是:为什么JDK里面还要包含一个JRE呢?因为JDK里面有很多小工具,这些小工具都是Java开发的,它们需要运行在JRE环境。

第十节:等待的艺术

1、无等待


public class Worker extends Thread
{
	public void run()
	{
		System.out.println(this.getName() + "子线程开始");
		try
		{
			// 子线程休眠五秒
			Thread.sleep(5000);
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}
		System.out.println(this.getName() + "子线程结束");
	}
}


public class WaitRoom
{
	public static void main(String[] args)
	{
		long start = System.currentTimeMillis();

		Thread thread = new Worker();
		thread.start();

		long end = System.currentTimeMillis();
		System.out.println("子线程执行时长:" + (end - start));
	}
}

在主线程中,需要等待子线程执行完成。但是执行上面的main发现并不是想要的结果:

子线程执行时长:0
Thread-0子线程开始
Thread-0子线程结束

很明显主线程和子线程是并发执行的,主线程并没有等待。

2、主线程等待一个子线程

对于只有一个子线程,如果主线程需要等待子线程执行完成,再继续向下执行,可以使用Thread的join()方法。join()方法会阻塞主线程继续向下执行。


public class WaitRoom2
{
	public static void main(String[] args)
	{
		long start = System.currentTimeMillis();

		Thread worker = new Worker();
		worker.start();

		try
		{
			worker.join();
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}

		long end = System.currentTimeMillis();

		System.out.println("子线程执行时长:" + (end - start));
	}
}

执行结果:

Thread-0子线程开始
Thread-0子线程结束
子线程执行时长:5002

3、主线程等待多个子线程


public class WaitRoom3
{
	public static void main(String[] args)
	{
		long start = System.currentTimeMillis();

		for (int i = 0; i < 5; i++)
		{
			Thread thread = new Worker();
			thread.start();

			try
			{
				thread.join();
			}
			catch (InterruptedException e)
			{
				e.printStackTrace();
			}
		}

		long end = System.currentTimeMillis();
		System.out.println("子线程执行时长:" + (end - start));
	}
}

在上面的代码套上一个for循环,执行结果:

Thread-0子线程开始
Thread-0子线程结束
Thread-1子线程开始
Thread-1子线程结束
Thread-2子线程开始
Thread-2子线程结束
Thread-3子线程开始
Thread-3子线程结束
Thread-4子线程开始
Thread-4子线程结束
子线程执行时长:25000

由于thread.join()阻塞了主线程继续执行,导致for循环一次就需要等待一个子线程执行完成,而下一个子线程不能立即start(),5个子线程不能并发。

要想子线程之间能并发执行,那么需要在所有子线程start()后,在执行所有子线程的join()方法。


public class WaitRoom4
{
	public static void main(String[] args)
	{
		long start = System.currentTimeMillis();

		List list = new ArrayList();
		for (int i = 0; i < 5; i++)
		{
			Thread thread = new Worker();
			thread.start();
			list.add(thread);
		}

		try
		{
			for (Thread thread : list)
			{
				thread.join();
			}
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}

		long end = System.currentTimeMillis();
		System.out.println("子线程执行时长:" + (end - start));
	}
}

执行结果:

Thread-0子线程开始
Thread-3子线程开始
Thread-1子线程开始
Thread-2子线程开始
Thread-4子线程开始
Thread-3子线程结束
Thread-0子线程结束
Thread-2子线程结束
Thread-1子线程结束
Thread-4子线程结束
子线程执行时长:5000

4、主线程等待多个子线程(CountDownLatch实现)

CountDownLatch是java.util.concurrent中的一个同步辅助类,可以把它看做一个倒数计数器。

初始化时先设置一个倒数计数初始值,每调用一次countDown()方法,倒数值减一,await()方法会阻塞当前进程,直到倒数至0。

同样还是主线程等待5个并发的子线程。修改上面的代码,在主线程中,创建一个初始值为5的CountDownLatch,并传给每个子线程,在每个子线程最后调用countDown()方法对倒数器减1,当5个子线程等执行完成,那么CountDownLatch也就倒数完成。

修改Worker,接收传入的CountDownLatch:


public class CountDownLatchWorker extends Thread
{
	private CountDownLatch countDownLatch;

	public CountDownLatchWorker(CountDownLatch countDownLatch)
	{
		this.countDownLatch = countDownLatch;
	}

	public void run()
	{
		System.out.println(this.getName() + "子线程开始");
		try
		{
			// 子线程休眠五秒
			Thread.sleep(5000);
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}

		System.out.println(this.getName() + "子线程结束");

		// 倒数器减1
		countDownLatch.countDown();
	}
}

public class WaitRoom5
{
	public static void main(String[] args)
	{
		long start = System.currentTimeMillis();

		// 创建一个初始值为5的倒数计数器
		CountDownLatch countDownLatch = new CountDownLatch(5);
		for (int i = 0; i < 5; i++)
		{
			// 传入CountDownLatch
			Thread thread = new CountDownLatchWorker(countDownLatch);
			thread.start();
		}

		try
		{
			// 阻塞当前线程,直到倒数计数器倒数到0
			countDownLatch.await();
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}

		long end = System.currentTimeMillis();
		System.out.println("子线程执行时长:" + (end - start));
	}
}

执行结果:

Thread-0子线程开始
Thread-2子线程开始
Thread-1子线程开始
Thread-3子线程开始
Thread-4子线程开始
Thread-2子线程结束
Thread-4子线程结束
Thread-1子线程结束
Thread-0子线程结束
Thread-3子线程结束
子线程执行时长:5000

注意:如果子线程中会有异常,那么countDownLatch.countDown()应该写在finally里面,这样才能保证异常后也能对计数器减1,不会让主线程永远等待。

另外,await()方法还有一个实用的重载方法:public boolean await(long timeout, TimeUnit unit),设置超时时间。

例如上面的代码,想要设置超时时间20秒,到了20秒无论是否倒数完成到0,都会不再阻塞主线程。返回值是boolean类型,如果是超时返回false,如果计数到达0没有超时返回true。

// 设置超时时间为20秒
boolean timeout = countDownLatch.await(20, TimeUnit.SECONDS);
if (timeout)
{
	System.out.println("所有子线程执行完成");
}
else
{
	System.out.println("超时");
}
第十一节:CAS入门简介

1、CAS是什么?

CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。

CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。

2、CAS的缺点有哪些?

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题:

(1)循环时间长开销很大。

(2)只能保证一个共享变量的原子操作。

(3)ABA问题。

2.1、循环时间长开销很大

并不是说CAS的时间开销很大,而是它所适用的场景所带来的时间开销很大。看下面的例子介绍:

在JDK 1.7中,AtomicInteger的getAndIncrement是这样的:


public final int getAndIncrement()
{
    for (;;)
    {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
        {
            return current;
        }
    }
}
public final boolean compareAndSet(int expect, int update)
{
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

我们可以看到getAndIncrement方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

2.2、只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性。

2.3、什么是ABA问题?ABA问题怎么解决?

如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?不能的。如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。

Java并发包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。见下面的例子:


public class ABA
{
	private static AtomicInteger atomicInt = new AtomicInteger(100);
	private static AtomicStampedReference atomicStampedRef = new AtomicStampedReference(100, 0);

	public static void main(String[] args) throws InterruptedException
	{
		Thread thread1 = new Thread(new Runnable() {
			@Override
			public void run()
			{
				atomicInt.compareAndSet(100, 101);
				atomicInt.compareAndSet(101, 100);
			}
		});

		Thread thread2 = new Thread(new Runnable() {
			@Override
			public void run()
			{
				try
				{
					TimeUnit.SECONDS.sleep(1);//让thread1先执行,从而造成ABA的场景
				}
				catch (InterruptedException e)
				{
				}
				boolean c3 = atomicInt.compareAndSet(100, 101);
				System.out.println(c3); // ABA场景,未发现变量被更新过,导致更新成功
			}
		});

		thread1.start();
		thread2.start();
		thread1.join();
		thread2.join();

		Thread thread3 = new Thread(new Runnable() {
			@Override
			public void run()
			{
				try
				{
					TimeUnit.SECONDS.sleep(1);//先暂停,让thread4拿到当前的版本号
				}
				catch (InterruptedException e)
				{
				}
				atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
				atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
			}
		});

		Thread thread4 = new Thread(new Runnable() {
			@Override
			public void run()
			{
				int stamp = atomicStampedRef.getStamp();
				try
				{
					TimeUnit.SECONDS.sleep(2);//等待thread3,造成ABA的场景
				}
				catch (InterruptedException e)
				{
				}
				//此时stamp已经是旧的版本号,不能再更新成功
				boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
				System.out.println(c3); // false
			}
		});

		thread3.start();
		thread4.start();

		thread3.join();
		thread4.join();

	}
}

3、CAS知识的深化

学习CAS,不仅要明白其自身的知识点,而且还有深化一下,跟其他的知识串联起来。这种串联可不要觉得是可有可无的。要用动态和发展的眼光看问题,这种串联将会变的很强大的。

具体与谁串联呢?应该与乐观锁串联,包括Java自身的锁知识,还有sql中的锁知识等等。

第十二节:锁的优化

1、减少锁的持有时间,避免给整个方法加锁


public class LockOpt
{
	public synchronized void syncMethod()
	{
		othercode1();
		mutextMethod();
		othercode2();
	}

	public void syncMethod2()
	{
		othercode1();
		synchronized (this)
		{
			mutextMethod();
		}
		othercode2();
	}
}

2、减小锁的粒度

将大对象,拆成小对象,大大增加并行度,降低锁竞争,JDK内置的ConcurrentHashMap与SynchronizedMap就是一正一反的对比。

Collections.synchronizedMap,其本质是在读写map操作上都加了锁,在高并发下性能一般。

ConcurrentHashMap,内部使用分区Segment来表示不同的部分, 每个分区其实就是一个小的hashtable,各自有自己的锁。只要多个修改发生在不同的分区, 是可以并发的进行。ConcurrentHashMap把一个整体分成了16个Segment, 最高支持16个线程并发修改。

3、读写分离锁替代独占锁

顾名思义, 用ReadWriteLock将读写的锁分离开来, 尤其在读多写少的场合, 可以有效提升系统的并发能力.

(1)读-读不互斥:读读之间不阻塞。

(2)读-写互斥:读阻塞写,写也会阻塞读。

(3)写-写互斥:写写阻塞。

4、锁分离

在读写锁的思想上做进一步的延伸, 根据不同的功能拆分不同的锁, 进行有效的锁分离。一个典型的示例便是LinkedBlockingQueue,在它内部, take和put操作本身是隔离的, 有若干个元素的时候, 一个在queue的头部操作, 一个在queue的尾部操作, 因此分别持有一把独立的锁.

         /** Lock held by take, poll, etc */
	private final ReentrantLock takeLock = new ReentrantLock();

	/** Wait queue for waiting takes */
	private final Condition notEmpty = takeLock.newCondition();

	/** Lock held by put, offer, etc */
	private final ReentrantLock putLock = new ReentrantLock();

	/** Wait queue for waiting puts */
	private final Condition notFull = putLock.newCondition();


第十三节:线程上下文
第十四节:纵观原子操作发展史

我们都知道,原子由原子核和绕核运动的电子组成。这是从物理状态而言的,原子可以分割的,但是在化学反应中原子是不可分割的。

原子是化学反应不可再分的最小微粒,原子是构成一般物质的最小单位,也称之为元素。

物质是由原子构成,这是客观存在的现象,在计算机领域,人的思想变得更抽象,更升华了一步:一个字节就是一个“原子”。因为当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址,所以每次访问总是能获得一个完整的字节,不会出现半字节。

字节即是原子,操作字节的读写操作则为原子操作。后来,人们把不可被中断的一个操作或多个操作称之为原子操作。

除了读字节和写字节是原子操作之外,还有一个更复杂的读写操作也是原子操作。这就是大名鼎鼎的CAS。CAS是英文单词CompareAndSwap的缩写,中文意思是“比较并替换”。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。

CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。

很多人看到CAS就感觉这个是高深的玩意,其实不然。可以看做是读字节和写字节的进阶而已。

第十五节:多线程的平等

多线程讲究的是平等,因平等而变多,因变多而威力无比。

但是,很多时候我们误解了平等,人为造成了不平等。我有一个观点:main不是上帝,它只是普罗大众的一员。main更应该叫做entry入口,正因为叫main才导致我们束缚了手脚。如果没有main字面意思的误导,我们就能深刻认识它只是入口的作用,可以启动无数个线程,这些线程各个能与main线程平起平坐。

冒出个main,还有什么平等可言呢。所以,我觉得,将main视为entry才是最合适的。

main线程只是一个普通的线程,在main线程里面,如果启动的其他的线程t,那么main线程和t线程将会轮流在CPU上运行,并不会因为线程是main线程而给以更高的优先级。

main线程和t线程是完全独立的,即便是main线程执行过程中出现问题,抛出异常发生了中断,但是t线程也不会受到影响的。

线程与线程之间,都是平等的,不存在main是上帝之说。

第十六节:数据的存储:缓存行和伪共享,工作内存和主内存

1、缓存一致性问题

如果系统只有一个CPU核在工作,一切都没问题。如果有多个核,每个核又都有自己的缓存,那么我们就遇到问题了:如果某个CPU缓存段中对应的内存内容被另外一个CPU偷偷改了,会发生什么?会发生缓存一致性问题。

2、缓存行

今天的CPU不再是按字节访问内存,而是以64字节为单位的块拿取,称为一个缓存行(Cache Line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存,并且访问同一个缓存行内的其它数据的开销是很小的。

3、伪共享

如果多个线程操作不同的成员变量,但是这些变量存储在同一个缓存行,如果有处理器更新了缓存行的数据并刷新到主存之后,根据缓存一致性原则,其他处理器将失效该缓存行导致缓存未命中,需要重新去内存中读取最新数据,这就是伪共享问题。



从上图看到,线程1在CPU核心1上读写变量X,同时线程2在CPU核心2上读写变量Y,不幸的是变量X和变量Y在同一个缓存行上,每一个线程为了对缓存行进行读写,都要竞争并获得缓存行的读写权限,如果线程2在CPU核心2上获得了对缓存行进行读写的权限,那么线程1必须刷新它的缓存后才能在核心1上获得读写权限,这导致这个缓存行在不同的线程间多次通过L3缓存来交换最新的拷贝数据,这极大的影响了多核心CPU的性能。如果这些CPU核心在不同的插槽上,性能会变得更糟。

4、工作内存与主内存



我个人有个建议:明白工作内存和主内存的概念术语之后,把它们扔掉,取而代之的是:寄存器和内存。也就是说,任何时候,碰到多线程的问题,在脑海里面出现的寄存器和内存,而不是工作内存和主内存。我感觉,工作内存和主内存这两个术语是坑,害人不浅。

第十七节:名相近性相远:进程、线程、协程

进程、线程和协程这三类事物,虽然都挂着一个“程”,但是名相近性相远。进程和线程是一类,而协程却是另外一类,它跟函数是一家。本文主要是讲述进程、线程、函数和协程之前的区别和联系,有助于各位读者朋友打破思维定势,做到融会贯通。

1、进程和线程

我们目之所见得到的是“代码”这个东西,其实是从静态的角度来看待的事物的。代码跑起来,则变成进程和线程,并且有了生命周期,这是从动态的角度看待事物的。我先从进程入手,然后延伸到线程。请看下面的代码:

public class HelloWorld
{
	public static void main(String[] args)
	{
		System.out.println("Hello World");
	}
}

执行如下命令:

$ javac HelloWorld.java 
$ java HelloWorld 

下面我进行命令的解读:

(1)javac HelloWorld.java,编译java代码,此处与本文主题无关,跳过即可。

(2)java HelloWorld,java命令表示启动了一个进程,这个进程是jvm进程。进程是资源的分配单位,此时CPU分配了各种资源给HelloWorld程序,然后CPU启动main线程。此时,我们可能听着音乐,看着小视频,CPU卖力地在音乐(线程)、视频(线程)和HelloWorld的main线程这三者之间不断地调度和上下文的切换。

需要注意的是:当HelloWorld的main线程执行结束,jvm进程也就结束了。

2、进程和线程小结

进程是资源的分配单位,而线程是运行调度单位。提到线程,往往与CPU时间片的切换密切相关。CPU调度放在线程的背景下去理解,往往简单直白。但是,一旦牵扯到协程,很多人就会迷惑不解,而下文讲的协程则与时间片的切换就没有什么关系了,协程纯粹就是函数级别的“编码技巧”而已。

3、协程:函数的让路

说完了进程和线程,下面该说一下协程了。虽然都挂着一个“程”,但是名相近性相远。进程和线程是一类,而协程却是另外一类,它跟函数是一类。所以,在说协程之前,我先从函数说起。

我们知道,函数定义之后就可以调用,在执行过程中,一气呵成,中间是不会“让路”的,函数没有执行完毕之前,是不会硬生生给其他函数“让路“的。说到“让路”问题,有人会想到生产者和消费者场景,生成者和消费者函数交替执行,相互“让路”。

对于生产者和消费者问题来说,那是因为存在两个线程,所以仓库货物不足的时候,消费者线程就暂停了,CPU回头就去执行生产者线程,等仓库堆上产品,消费者线程继续执行。所以:消费者线程->仓库货物不足->生产者线程->消费者线程继续执行,这个过程中消费者线程没有“让路”,而是CPU的调度到了生产者线程。

协程,立足于函数,就是在函数上做了改造,它可以实现“让路”。那如何对这个函数进行改造呢?主要的改造有以下几点:

(1)不用return返回值,取而代之用yield返回中间的结果,其后就可以让路。return用于函数的一气呵成之后的最终返回结果,而yield用于让路之前返回结果。

(2)通常情况下,定义函数define do()之后,调用函数do()之后,将一气呵成返回最终结果,而改造之后,调用函数do()之后,函数不再立即执行,而是函数被包装成一个生成器。整个生命周期是这样的:定义函数a->调用函数a->函数包装成生成器A->激活生成器A,执行半路然后返回结果完成让路->执行其他函数b->再次激活生成器A->生成器A从上次让路的地方继续往下执行

函数的让路就是协程。关于协程的代码演示请见下面所示。此处是Python代码,但不影响非Python用户的理解,因为本文主要是从函数的角度进行分析,函数的通用规范在各种语言中大同小异,例如使用return返回值,中间不能暂停等等通用规范。


def a():
    print("a header...")

    yield print("a body...")

    print("a tail...")


def b():
    print("b function...")

# 因为a函数中有yield关键字,所以a函数并不会真的执行,而是先得到一个生成器,相当于一个对象
A = a()

# 调用next方法,相当于给A生成器发送消息,a函数正式开始执行,先执行a函数中的print方法

# 然后程序遇到yield关键字,此时把yield想成return即可,print("a body...")之后,程序暂停
next(A)

# 其实a函数没有执行完,这个时候可以执行b函数,
b()
# 这个时候是从上面那个next程序停止的地方开始执行的,也就是执行print("a tail..."),
next(A)

4、函数和协程小结

协程的本质就是函数,是跑在一个线程里面的,是函数的让路,无关乎CPU时间片的调用。

协程的创建本质就是普通函数的创建,非常轻量级,从而使你可以在一个线程里面轻松创建数十万个协程,就像数十万次函数调用一样。可以想象一下,如果是在一个进程里面创建数十万个线程,结果该是怎样可怕。

综之,我个人觉得:认识到协程的本质就是函数,这样才能明白协程。

第十八节:CopyOnWrite入门教程

1、CopyOnWrite简介

CopyOnWrite容器,即写时复制的容器。通俗的理解是,当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行复制,复制出一个新的容器,然后往新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是,我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。

以CopyOnWriteArrayList为例,当向其添加元素的时候是需要加锁的,否则多线程写的时候会复制出多个副本出来。


public boolean add(T e) 
{
    final ReentrantLock lock = this.lock;
    lock.lock();
    try 
	{
        // 复制出新数组
        
        // 把新元素添加到新数组里

        // 把原数组引用指向新数组
    } 
	finally 
	{

        lock.unlock();
    }
}

读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读线程还是会读到旧的数据。

2、CopyOnWrite思想的来源

CopyOnWrite思想由来已久,源于Unix系统的进程的分叉创建。

利用fork()函数,Unix系统可以对进程进行复制,从而创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果后期传入的变量不同,两个进程也可以做不同的事。

初期Unix系统实现了一种傻瓜式的进程创建:当执行fork系统调用时,内核复制父进程的整个用户空间并把复制得到的那一份分配给子进程。

写时复制(Copy-on-Write)是一种可以推迟甚至避免复制数据的技术。内核此时并不是复制整个进程空间,而是让父进程和子进程共享同一个副本。

只有在需要写入的时候,数据才会被复制,从而使父进程、子进程拥有各自的副本。也就是说,资源的复制只有在需要写入的时候才进行,在此之前以只读方式共享。这种技术使得对地址空间中的页的复制被推迟到实际发生写入的时候。有时共享页根本不会被写入。

3、CopyOnWrite容器的用法

CopyOnWrite容器本质还是容器,容器怎么用,你就怎么用。无非就是增删改查吧。

以CopyOnWriteArrayList为例,它的使用和ArrayList差不多,其实没什么好说的。大家放心大胆的使用就行了。

最简单的做法是:先用ArrayList完成程序,然后全文替换成CopyOnWriteArrayList即可,如果有点意外,酌情修改一下就可以了。

第十九节:线程池的核心参数(别样角度,更有启发)

这是发生在小商品天堂-义乌的故事,当地有一个工厂,工厂里面有9个工人,每人同时只能做一件任务。因此,只要当9个工人中有人是空闲的,来了任务就分配给空闲的工人。当9个工人都有任务在做时,如果还来了任务,就让任务进行排队等待。

如果新任务数目增长的速度远远大于工人做任务的速度,那么此时工厂的主管可能会想其他的措施,例如重新招5 个临时工人进来,然后就将任务也分配给这5个临时工人做。

如果这14个工人做任务的速度还是不够,此时工厂主管可能就要考虑不接受任务或者抛弃掉前面的一些任务。

当这14个工人当中有人空闲,而新任务增长的速度又比较慢,主管可能就考虑辞掉5个临时工了,只保持原来的9个工人,毕竟请额外的工人是要花费成本的。

工厂的生成流程,跟计算机领域的线程池技术,有异曲同工之妙。

线程池有两大核心参数:

(1)核心池大小( corePoolSize),即固定大小,设定好之后,池的线程数大小不会释放的。

(2)最大处理线程池数(maximumPoolSize)

在这个实例中永远等待干活的9个工人,就是corePoolSize,而maximumPoolSize 就是14 ( 9+5)。也就是说corePoolSize 就是线程池大小,maximumPoolSize是线程池的一种补救措施,即任务量突然过大时的一种补教措施。

备注:文中有数字9和5,可联想到”九五之尊”,方便记忆。

第二十节:单线程->多线程->消息中间件

多线程最主要目的是最大限度地利用CPU资源,如果CPU总共有10个线程,你占其中一个,则CPU的利用率是十分之一,如果你再多加一个线程,那么CPU的利用率将是五分之一,你赚大发了。

多线程可以让任务执行的更快,但是多线程并不是技术的终点,往往需要消息中间件来实现异步处理。下面这个例子取材于互联网,笔者做了些精简,敬请注意。其实这个场景在日常开发中经常碰到。

1、单线程

假设在用户登录时,如果是新用户,需要创建用户信息,并发放新用户代金券。代码如下所示:


// 登录函数
public User login(String phone, String verifyCode) 
{    // 检查验证码    
    if (!checkVerifyCode(phone, verifyCode)) 
    {        
        throw new VerifyException("验证码错误");    
    }
    // 检查用户存在    
    UserDO user = userDAO.getByPhone(phone);    
    if (user!=NULL) 
    {        
        return user;    
    }
    // 创建新用户    
    return createNewUser(user);
}

// 创建新用户函数
private User createNewUser(String phone) 
{    // 创建新用户    
    User user = new User();   
    userDAO.insert(user);
    // 绑定代金券    
    couponService.bindCoupon(user.getId(), CouponType.NEW_USER);
    // 返回新用户    
    return user;
}

其中,绑定代金券(bindCoupon)是给新用户绑定代金券,然后再给用户发送推送通知。如果代金券种类越来越多,该函数也会变得越来越慢,执行时间过长,但是没有什么优化空间。

2、多线程

现在,登录函数的执行时间过长,需要进行优化。通过分析发现,绑定代金券(bindCoupon)函数可以异步执行,可以采用多线程解决该问题,代码如下:


// 创建新用户函数
private User createNewUser(String phone) 
{    // 创建新用户    
    User user = new User();    ...    、
    userDAO.insert(user);
    // 绑定代金券    
    executorService.execute(()->couponService.bindCoupon(user.getId(), CouponType.NEW_USER));
    // 返回新用户
    return user;
}

现在,在新线程中执行绑定代金券(bindCoupon)函数,使用户登录函数的性能得到很大的提升。

3、消息队列

但是,如果在新线程执行绑定代金券函数过程中,系统发生重启或崩溃导致线程执行失败,用户将永远获取不到新用户代金券。所以,用采用多线程优化程序,并不是一个完善的解决方案。

如果要保证绑定代金券函数执行失败后能够重试执行,可以采用数据库表、Redis队列、消息队列的等多种解决方案。这里只介绍采用消息队列解决方案。


// 创建新用户函数
private User createNewUser(String phone) 
{        // 创建新用户    
        User user = new User(); 
	userDAO.insert(user);
        // 发送代金券消息    
	Long userId = user.getId();    
	CouponMessageData data = new CouponMessageData();    
	data.setUserId(userId);    
	data.setCouponType(CouponType.NEW_USER);    
	Message message = new Message(TOPIC, TAG, userId, JSON.toJSONBytes(data));    
	SendResult result = MQSender.sendMessage(message);    
	if (!Objects.equals(result, SendStatus.SEND_OK)) 
	{   
            //短信报警
            return;
	}
        // 返回新用户    
	return user;
}

// 代金券服务类
public class CouponService 
{       // 消息处理函数
	public void onReceiveMessages(MQMessage message) 
	{
	    // 获取消息体
	    String body = message.getBody();        
	    if (StringUtils.isBlank(body)) 
	    {
		    return;
	    }
            // 解析消息数据
            CouponMessageData data = JSON.parseObject(body, CouponMessageData.class);
	    if (Objects.isNull(data)) 
	    {
		    return;
	    }
            // 绑定代金券
	    bindCoupon(data.getUserId(), data.getCouponType());    
	}
}

采用消息队列优化的优点:

(1)如果系统发生重启或崩溃,导致消息处理函数执行失败,不会确认消息已消费,等到本服务恢复正常后再进行消费。

(2)消费者可多服务、多线程进行消费消息,即便消息处理时间较长,也不容易引起消息积压。即便引起消息积压,也可以通过扩充服务实例的方式解决。

备注:

文中例子从互联网改编而来。

第二十一节:线程的堵塞

线程本来就是一个看不见摸不着的东西,现在冒出个“堵塞”状态,人们更是丈二和尚摸不着头脑。

其实,可以把线程看成是指令队列,通过这种角度去分析问题,往往能迎刃而解。



线程的堵塞,就是线程卡在某个节点,不往前走了。也可以说,CPU卡在某个指令节点上,不再往前走了。

什么情况下线程被卡在某个指令节点上呢?例如,读数据的时候,数据还没有到,那么CPU走到某个指令节点就会暂停,挂起线程,切换到其他程序。等了若干时间片,CPU会再次切换回来,再检查一遍,如果数据还没有到,那么它继续挂起,再次切换到其他程序。如此反复。

再如,某个指令节点是sleep命令,CPU就会把线程挂起,切换到其他的程序,等了若干时间片,CPU会再次切换回来,再检查一遍,如果休息的时间还没有到,那么它继续挂起,继续切换到其他程序。如此反复。

要注意一点,堵塞不同于死循环。从应用程序的角度来说,发生死循环,程序无法进入下一个环节的任务,但是在CPU的角度来说,仍然是一个指令节点紧接另一个指令节点的推进。

答疑解惑

各位读者,读完这个系列内容,如果还有不太明白的地方,欢迎过来讨论,请在公众号下留言即可。



理不辨不明,欢迎大家的交流和讨论,当然,关于多线程的内容也可以过来讨论。无他,只因为爱好和兴趣。


MyBatis中文官网 @ 2020年