此处输入图片的描述


学习Dockerfile,需要对它的基础命令进行了解

FROM - 基于基准镜像

尽量使用官方提供的基准镜像

  • FROM tomcat:8.5.50-jdk8-openjdk #制作基准镜像(基于tomcat:8.5.50-jdk8-openjdk)
  • FROM centos #制作基准镜像基于centos:latest
  • FROM scratch #不依赖任何基准镜像

LABEL & MAINTAINER - 说明信息

  • MAINTAINER albk.tech #当前镜像由哪个个人/机构维护
  • LABEL version=”1.0” # 说明其它描述信息,这些信息不产生功能影响,只是方便阅读
  • LABEL description - “BK个人博客”

WORKDIR - 设置工作目录

WORKDIR 支持相对路径和绝对路径,建议使用绝对路径,这样Dockerfile看起来更加清晰,更容易进行维护

  • WORKDIR /usr/local/albk #设置工作目录和linux的cd命令类似,但是如果目标目录不存在,具有自动创建目录的功能

ADD & COPY - 设置工作目录

  • ADD hello / # 将hello文件夹复制到容器的根路径下
  • ADD test.tar.gz / # 解压缩test.tar.gz并复制到容器的根路径下
  • ADD除了复制,还具备添加远程文件的功能,类似于wget 和curl功能,在实际使用中比较少

ENV - 设置环境常量

尽量使用环境常量, 设置常量后,后面的代码可以使用该常量,修改时只需要修改常量定义即可,可提高程序的维护性

  • ENV JAVA_HOME /usr/local/openjdk8 设置JAVA_HOME常量
  • RUN ${JAVA_HOME}/bin/java -jar albk.jar #使用常量执行指令

RUN & CMD & ENTRYPOINT - 执行指令

这3条指令都可以执行命令,他们的区别如下

  • RUN #在build构建时执行命令
  • ENTRYPOINT #容器启动时执行命令
  • CMD #容器启动后执行默认的命令或参数

本质上是执行时机不同
RUN上在构建镜像时(docker build),对镜像内部文件或资源进行调整,创建后镜像是只读的不允许修改
CMD|ENTRYPOINT在容器创建(docker run )时执行,对容器内资源进行修改

RUN-构建时运行

  • RUN yum install -y vim #Shell命令格式

  • 使用该方式,会创建一个子进程去执行命令

  • 执行完毕之后,子进程销毁,并返回主进程

  • RUN ["yum","install","-y","vim"] # Exec命令格式

  • 使用该方式,会用Exec进程替换当前进程,并且保持PID不变

  • 执行完毕,直接退也,并不会返回之前的进程环境

这两种命令格式,推荐使用后者, 本质区别就是是否创建子进程

ENTRYPOINT 启动命令 - 入口点

  • ENTRYPOINT 用于在容器启动时执行命令
  • 如果在程序中存在多个ENTRYPOINT的话,只有最后一个会被执行
  • ENTRYPOINT [“ps”] 推荐使用Exec格式

CMD默认命令

  • 用于设置默认执行的命令
  • 如果Dockerfile中出现多个CMD,只有最后一个被执行
  • CMD被称为默认命令是指,如果在容器启动时,没有附加指令,使用的默认指令
  • 如果容器启动时附加指令则CMD会被忽略
  • CMD [“ps” , “-ef”] 推荐使用Exec格式

CMD与ENTRYPOINT区别对比

CMD如果启动附加了指令,则不会执行,而ENTRYPOINT一定会被执行

创建Dockerfile

mkdir -p /usr/local/docker/commandtest
cd /usr/local/docker/commandtest
vim Dockerfile

1
2
3
4
FROM tomcat
RUN ["echo" ,"image building ...."]
ENTRYPOINT ["ps"]
CMD ["-ef"]

保存退出

编译Dockerfile

`docker build -t albk.tech/commandtest .

可以看到RUN命令内容在docker build阶段,已经执行`

此处输入图片的描述

docker run albk.tech/commandtest

此处输入图片的描述

  • 可以看到ENTRYPOINT和CMD命令进行合并了,然后执行了组合命令ps -ef
  • 这样可能看不出他们的区别,我们接下来看一下
    docker run albk.tech/commandtest -aux

此处输入图片的描述

在运行时传入 -aux之后,最终运行的命令是ps -aux,所以说CMD命令不一定执行,当在运行时候,有指定命令,会运行指定的代码,而不是默认代码,这样的组合有很大的好处,可以在不改变Dockerfile的前提下,提供更灵活的配置

写在最后

相关文章会首发公众号,可以关注公号albk,关注后可以获取大量学习视频

此处输入图片的描述

前言

涉及到的命令,可以参考上一篇Docker学习(三)认识Docker和常用命令

可以参考docker官方网站

业务场景

公司采购了一台新的硬件服务器,领导说,BK这台服务器用tomcat搭建一台web服务器吧,这个任务看起来简单,但是很繁琐

  • 安装linux系统
  • 下载jdk,tomcat,
  • 安装jdk,配环境变量
  • 安装tomcat

即使对于一个老司机,也要花挺长时间,但是使用docker可以很大的简化这个过程

查看可用镜像

在docker官网搜索tomcat组件,在每个tag中可以看到具体使用的JDK版本和Tomcat版本
tocmat可用版本

此处输入图片的描述

搭建tomcat

  • 下载tomcat镜像
    docker pull tomcat:latest

此处输入图片的描述

  • 查看本地镜像
    docker images

此处输入图片的描述

启动tomcat

docker run -p 8888:8080 -d tomcat
-d 是后台运行
-it 是interaction 交互命令
-p 8000:8080 进行端口绑定, 8000宿主机端口,8080是容器中tomcat默认的端口

此处输入图片的描述

docker ps
docker exec -it 257dcfd4ea5e /bin/bash 进入tomcat docker容器内部

此处输入图片的描述

停止tomcat

  • 查看运行的docker容器
    docker ps
  • 方法一
    docker stop 容器编号
    docker rm 容器编号
  • 方法二
    docker rm -f 容器编号

    开发测试时候可以使用, 线上环境不建议,避免出现意料之外的问题

移除镜像

  • docker images
  • docker rmi image:tags 如果有当前镜像创建的容器,需要先移除容器
    docker rmi -f image:tag 强制移除镜像,生产环境不建议使用

此处输入图片的描述

附录

tomcat为什么要进行端口绑定

因为tomcat启动之后,默认的端口是8080,是容器对外暴露的端口客户端访问是访问宿主机的ip和端口,无法直接访问容器暴露的端口,所以需要将宿主机的端口与容器暴露的端口进行绑定,才可以从浏览器访问

此处输入图片的描述

-p 8000:8080 端口映射之后

此处输入图片的描述

这种端口绑定的方式,提供了一种好处,
只要客户端知道宿主机和端口,内部的容器可以随便更换,比如说现在的tomcat性能跟不上,我们可以更换成jetty,只要保证外侧的端口映射8000不变,就可以无缝迁移

端口映射之后可以通过命令netstat -tulpn查看到,后台启了一个docker-proxy进行请求转发
此处输入图片的描述

拉取镜像时,不写版本默认是什么版本

在拉取镜像时不写版本号,默认是latestlatest的定义实际上不是最新的,而是使用最多的版本

tag怎么选择

  • 镜像名称和组件名一样的是官方提供 ,右侧有它的starts 表明发前版本的热度
  • 如果前面有/,则是第三方发布的, 除非你对这个项目特别了解,否则最好不要使用,因为我们不确定里面是否有恶意代码

写在最后

使用 docker搭建环境很方便,对于运维工作的效率有很大的提升,安装redis , nginx ,mysql 方法都类似,相关学习可以到**docker官网**搜索相关组件,在组件详情页中都有相关的命令跟着,官网命令可以一步一步的学习

相关文章会首发公众号,可以关注公号albk,关注后可以获取大量学习视频

此处输入图片的描述

Docker体系结构

此处输入图片的描述

  • docker服务端,作为服务的提供方,核心进程 docker daemon,所有docker命令都是通过这个进程完成的

  • REST API:通信层,基于WEB容器提供http协议服务

  • docker CLI: 提供常用的docker命令

基于HTTP协议,docker的兼容性和扩展性是特别好的
基于这种CS架构,对于应用来说,最大的好处就是服务器与客户端可以不用安装在同一台电脑上,可以跨机器管理多台服务器,提高运维效率

Docker命令

  • docker pull 镜像名<:tags> -从远程仓库拉取镜像
  • docker images - 查看本地镜像
  • docker run 镜像名<:tags> -创建容器,启动应用
  • docker ps - 查看正在运行中的镜像
  • docker version - 查看docker版本
  • docker rm <-f> 容器ID - 删除容器 -f 强制删除,生产环境禁用强制删除
  • docker rmi <-f> 镜像ID - 删除镜像,-f强制删除,生产环境禁用强制删除
  • `docker build -t 机构/镜像文件<:tags> Dockerfile目录 - 编译dockerfile镜像配置文件,生成自定义镜像文件
  • `docker exec -it 容器id - 运行docker容器

Docker执行过程

此处输入图片的描述

  • Docker Host是我们的docker宿主机
  • Registry是docker拉取镜像的远程仓库,提供大量的镜像供下载,下载完成之后保存在Images中
  • Docker Daemon是docker的服务线程,处理客户端命令
  • Images 是本地的镜像仓库,可以通过docker images查看
  • Docker Daemon是docker的服务线程,处理客户端命令

执行过程

  • 客户端通过rest api 发送指令到 docker host的docker daemon进程,
  • Docker daemon进程处理不同的指令,交将结果返回到客户端

以docker pull 命令为例

  1. 客户端通过rest api将指令发送给docker daemon
  2. docker daemon 先检查本地images中有没有相关的镜像
  3. 如果本地没有相关的镜像,则向镜像服务器请求,将远程镜像下载到本地
  4. 返回客户端

写在最后

相关文章会首发公众号,可以关注公号albk,关注后可以获取大量学习视频

前言

windows10 64位专业版本才可以使用docker
Centos安装可以参考 ,基于安装完成的Centos进行今天的docker安装

  • Docker学习(一)环境准备安装centos7

环境准备

  1. 安装docker持久化包和yum工具包

yum install -y yum-utils device-mapper-persistent-data lvm2

  • yum-utils 简化yum安装的工具包,后面会用到相关命令修改镜像加速,非必选
  • device-mapper-persistent-data lvm2:安装docker数据存储的驱动包,必须安装
  1. 修改镜像安装源到阿里云加速,默认是国外的下载比较慢

yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum自动选择最快的安装源
yum makecache fast

安装 docker

docker 相关的命令会在后续文章中详细讲解

  • 安装开源设区版本docker,视网速快慢,大根需要1-2分钟

yum -y install docker-ce

  • 启动docker

service docker start

  • 查看docker版本,分两部分,客户端信息,服务端信息

docker version

  • 下载hello-word镜像

docker pull hello-word

  • 运行hello-word

docker run hello-word
看下如下提示表示运行成功

1
2
Hello from Docker!
This message shows that your installation appears to be working correctly.

遇到的问题

Q:docker pull hello-word 失败

A:因为docker默认连接国外服务器,所以下载可能失败,可以使用阿里云镜像加速代理

阿里云Docker镜像加速

镜像加速服务

此处输入图片的描述

需要登录之后才可以看到加速的后台地址,每个用户的加速地址是不同的
复制相关的加速脚本,在centos中运行即可完成加速

1
2
3
4
5
6
7
8
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": ["https://ki61hxwi.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

写在最后

相关文章会首发公众号,可以关注公号albk,关注后可以获取大量学习视频

前言

Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的 Linux或Windows 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。

本系列文章我们以linux作为我们的运行平台,首先先准备一台linux服务器,我们以centos7为例,不管是在windows ,mac 平台安装centos都很方便,网上可以找到很多资料,本文以mac安装为例,如何安装centos.

Mac上的虚拟机软件有很多,可以自行选择,VMWare Fusion 、VirtualBox、Parallels Desktop

因为后续会分享一些k8s相关的内容,所以需要linux服务器,如果只是学习docker,可以在windows或者mac平台上,直接安装docker应用也是可以的

资源准备

  1. VMware Fusion 11.pro
    下载地址:下载地址
    版本: Fusion 11.5 Pro
  2. centos7镜像
    文章最后有提供

安装VMware Fusion

mac下根据提示完成,完成之后输入激活码,激活码文章最后有获取方法

创建虚拟机 & 安装Centos7

第1步
此处输入图片的描述

第2步
此处输入图片的描述

第3步
此处输入图片的描述

第4步
此处输入图片的描述

第5步
此处输入图片的描述

第6步
此处输入图片的描述

第7步
此处输入图片的描述

第8步
此处输入图片的描述

第9步
此处输入图片的描述

第10步
此处输入图片的描述

第11步
此处输入图片的描述

第12步
此处输入图片的描述

第13步
此处输入图片的描述

第14步
此处输入图片的描述

第15步
此处输入图片的描述

第16步
此处输入图片的描述

安装完成之后会自动启centos,输入之前配置的账号和密码即可

启动centos7

安装之后会自动启动系统,如果后续关机之后需要启动,可以在虚拟机列表找到需要启动的虚拟机,右击选择启动,即可,如下图
此处输入图片的描述

遇到的问题

Q:如果需要图形界面怎么办?

A:整个过程很简单,默认是最小安装,如果需要图形页面,可以在第10步的 SOFTWARE SELECTION 中修改安装类型,界面如下图 此处输入图片的描述
或者是在最小安装之后,再安装图形界面,具体操作可以谷歌一下,我是在最小安装之后又自已安装的图形界面

Q:安装之后无法复制信息到centos系统中

A:需要安装VMware Tools

Q:装完centos之后,启动总是了让安装系统页面?

A:未设置启动盘,在设置中,选择启动盘,选择刚装完系统的硬盘,最后点击重新启动即可 (这个设置类似window装后,设置启动盘一样,因为我是两块虚拟硬盘,选择正确的硬盘,标识为硬盘启动即可),如下图
此处输入图片的描述
此处输入图片的描述

写在最后

本文中所需要的软件和激活码已经打包好,百度云分享,可以关注公号albk,回复指令”docker install“获取

获取父线程MDC中的内容 Map<String, String> context = MDC.getCopyOfContextMap();

设置contentMap MDC.setContextMap(context);

清空MDC内容 `MDC.clear();

实现线程池传递MDC
*{@link ThreadPoolTaskExecutor}的一个简单替换,可以在每个任务之前设置子线程的MDC数据。

  • 在记录日志的时候,一般情况下我们会使用MDC来存储每个线程的特有参数,如身份信息等,以便更好的查询日志。
  • 但是Logback在最新的版本中因为性能问题,不会自动的将MDC的内存传给子线程。所以Logback建议在执行异步线程前
  • 先通过MDC.getCopyOfContextMap()方法将MDC内存获取出来,再传给线程。
  • 并在子线程的执行的最开始调用MDC.setContextMap(context)方法将父线程的MDC内容传给子线程。
  • https://logback.qos.ch/manual/mdc.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    public class MdcThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {

    /**
    * 所有线程都会委托给这个execute方法,在这个方法中我们把父线程的MDC内容赋值给子线程
    * https://logback.qos.ch/manual/mdc.html#managedThreads
    *
    * @param runnable
    */
    @Override
    public void execute(Runnable runnable) {
    // 获取父线程MDC中的内容,必须在run方法之前,否则等异步线程执行的时候有可能MDC里面的值已经被清空了,这个时候就会返回null
    Map<String, String> context = MDC.getCopyOfContextMap();
    super.execute(() -> run(runnable, context));
    }

    /**
    * 子线程委托的执行方法
    *
    * @param runnable {@link Runnable}
    * @param context 父线程MDC内容
    */
    private void run(Runnable runnable, Map<String, String> context) {
    // 将父线程的MDC内容传给子线程
    if (context != null) {
    try {
    MDC.setContextMap(context);
    } catch (Exception e) {
    logger.error(e.getMessage(), e);
    }
    }
    try {
    // 执行异步操作
    runnable.run();
    } finally {
    // 清空MDC内容
    MDC.clear();
    }
    }
    }

在之前文章聊聊JMM,说到了内存屏障,内存屏障在Java语言实现一致性内存模型上起到了重要的作用,本文我们一起聊一聊内存屏障

内存屏障是什么

​ 在cpu执行指令的过程中,对于同一个线程中没有数据依赖的指令可以重新排序优化,有数据依赖的指令按照顺序串行执行,来保证单线程程序运行的正确性,同时也提升了CPU的执行效率,合理的利用了CPU等待时间,
​ 在多核CPU的情况下,因为多核CPU上的指令同时执行,如果涉及到共享变量的修改,这种优化会影响多线程运行的正确性,而内存屏障(memory barrier/memory fence)是硬件层面提供的一系列特殊指令,当CPU处理到这些指定时,会做一些特殊的处理,可以使处理器内的内存状态对其它处理器可见,在不同的平台上支持的内存屏障也会有差异。

解答之前的疑问

在之间的文章聊聊缓存一致性协议中,结尾提到一个问题:MESI频繁的消息请求与响应带来的性能问题如何解决?

MESI协议解决了缓存一致性问题,但是频繁的请求与响应,会产生大量的等待时间,请求等待响应的返回之后才能将数据写入高速缓存中,为了避免减少这种性能问题,硬件层面引入了写缓存(store/write buffer)和无效化队列(invalidate queue), 结构如下图

写缓冲器(store buffer也称为 write buffer)是处理器内部的一个容量比高速缓存还小的私有高速缓存部件,每个处理器都有自己的写缓冲器,写缓冲器内部包含若干个条目,并且写缓冲器之间是无法直接访问的。引入写缓冲器,使得处理器在执行写操作的时候,写入写缓冲器中,而不需要等待response响应,来减少写操作的延时,在节省的时间内可以执行更多其它指令,从而提高处理器的执行效率。

无效化队列是用处理Invalidate消息的,当该消息被广播到总线上,其它的CPU都在监听此消息,同其它的CPU都要回复一个Invalidate Response消息,这会产生大量的广播事件,所以在引入无效化队列之后,处理器在收到Invalidate消息之后,并不立马删除地址中对应的副本数据(其实是更新缓存行的状态为无效),而是将消息存入无效化队列之后就直接响应Invalidate Response消息了,从而减少了写操作执行处理器的等待时间。

通过写缓冲器和无效化队列的,将消息累积起来,立马响应请求,提高处理器执行效率,然后在特定的时间(写缓冲满之后或者执行到内存屏障 ),批量将写缓冲中的数据写回主存,将无效化队列应用到高速缓存中,但是他们的引入,又带来了内存重排序和可见性问题。

写缓冲器和无效化队列带来的问题

  • 写缓冲器导致StoreLoad重排

  • 写缓冲器导致StoreStore重排

  • 无效化队列导致LoadLoad重排

  • CPU对共享变量的更新,到达写缓冲器中就返回了,这就可能导致其它CPU无法读到共享变量的最新值,因为共享变量的修改还在前一个CPU的写缓冲器中,连高速缓存都没到了, 无法通过MESI协议保证一致性,这个现象就是可见性问题

  • CPU对于Invalidate的请求,到达无效化队列之后就返回了,还没有将高速缓存中的相关副本数据删除,这就可能导致该处理器读了的数据是过时的数据 ,从而导致更新丢失 ,这个现象也可以理解为是可见性问题

    对于上面的问题,用到的就是我们今天的主角,内存屏障

内存屏障分类与作用

在X86平台提供了几种主要的内存屏障

  1. lfence - 加载屏障
  • 清空无效化队列,根据无效化队列中内容的内存地址,将相应处理器上高速缓存中的缓存条件状态置为I,使后续对该地址的读取时,必须发送Read消息,具体过程可参考 聊聊缓存一致性协议
  • 用在读指令前,阻止屏障两边的读指令重排
  1. sfence - 存储屏障:
  • 冲刷写缓冲器中的内容,将写缓冲器中内容的更新应用于高速缓存
  • 用在写指令之后,阻止屏障两边的写指令重排(执行到该屏障时,将对缓存中的条目打标记,标识这些条目需要在该屏障之前提交,当执行到写操作时,检测到写缓冲器中存在被标记的条目,不管写操作对应的条目状态,即使是E,M也不将写操作的数据回写高速缓存,而是写入写缓冲器的方式,使得屏障之间和屏障之后的指令修改都串行在写缓冲器中,来保证其顺序)
  1. mfence - 全能屏障
  • 具备ifence和sfence的能力, 实现是通过加载屏障和存储屏障的成对使用,可以保证写缓冲的内容同步到高速缓存,无效化队列的内容应用到高速缓存,然后再根据缓存一致性协议保证共享数据的一致性
  • 阻止指令重排

结束

下一篇可能会写一篇java中使用内存屏障实现的语义

什么是JMM

JMM (Java Memory Model )Java内存模型是一个语言级别的内存模型抽象,它屏蔽了底层硬件实现内存一致性需求的差异,提供了对上层的统一的接口来提供保证内存一致性的编程能力。

Java作为一个跨平台的语言,Java内存模型作为一个中间层模型,它适配不同的底层硬件系统,设计一个中间层模型来屏蔽底层的硬件差异,给上层的开发者提供一个一致的使用接口,它为开发者屏蔽了底层的硬件实现细节,支持大部分的主流硬件平台。

一致性模型

任何语言编写出来的程序,最终都会转换为机器指令,按照一定的顺序去执行,所以在语言层面来讲,都是基于硬件层面提供的一致性模型基础上,来实现自身语言的功能和特性,如何保证语言层面的功能,在转换为硬件指令之后,根据语言的要求正确的运行,这就需要语言提供一种一致性模型的编程能力。Java内存模型就是java语言级别的内存模型抽象

模型一致性

模型一致性不止下面列的4种,根据概念的严格化,可以分为很多种,严格一致、顺序一致、因果一致等,在本文中我们只列4种,这4种对我们后期理解 Java内存模型有很大的帮助

顺序一致性 Sequential Consistency

​ 也是常说的串行语义,它不要求全局的时钟顺序,只需要各个CPU局总的时钟顺序,

​ 对于每个CPU来说,它看到自己程序的执行顺序始终是和程序定义是一致的,

​ 从全局角度来看,相同的程序,在不同的CPU上运行,程序运行结果给人的感知顺序和是符合串行语义的,结果最终的视图是一致的

弱一致性 Weak Consistency

弱一致性是对被同步操保护的共享变量而言,系统中的某个数据被更新后,后续对该数据的读取操作可能得到更新后的值,也可能是更改前的值。它规定了只有对共享变量的同步操作完成之后,后续对该数据的读取都是更新后的值,同步操作这段时间被称为“不一致时间窗口”。

释放一致性 Release Consistency

弱一致性的粒度太大,包含了进入同步操作和释放同步操作两部分,而只有同步操作整体完成后,其他处理器才有可能保持一致性。 释放一致性规定了对同步变量的释放操作后,就对同步变量的状态广播到其他处理器,保证后续对该数据的读取都是更新后的值

进入一致性 Entry Consistency

和释放一致性一样,也是为了减小弱一致性的粒度,进入同步变量时,获取同步变量的最新状态,保证可以拿到数据更新后的最新值

再看Java内存模型

Java内存模型是一种比较松散的语言级内存模型,它通过加内存屏障(Memory Barrier),实现对共享数据的弱一致性、释放一致性、进入一致性的支持。

最终语言是以机器指令在运行,JMM适配不同硬件平台,操作硬件指令,来提供一种语言层面满足一致性模型的编程能力,使开发者不需要关心代码是以什么顺序执行,只要能够正确的使用JMM提供的同步原语,就可以保证程序最终表现出来的行为是正确的。

JMM适配了底层硬件,根据硬件指令提供一些同步原语,实现程序在多线程情况下的可见性,原子性、有序性,主要通过这些原语维护一套语言层面的运行规则 ,来保证语言层面的多线程下表现出来和串行语义一致的结果。

JMM的这种一致性模型的编程能力,使程序员不需要关运行的代码在执行时候是乱成什么顺序,以什么顺序 执行,只要能够正确的使用JMM提供的同步原语,就可以保证程序最终表现出来的行为是正确的。

主要功能就是屏蔽各硬件平台的差异,通过NATIVE方法,操作硬件指令组合,来实现JAVA中的语义,保证可见性,有序性,原子性。

  1. JMM提供的语义
  • 使用LOCK#信号、内存屏障实现语言层面的synchronized语义,保证复杂指令块的原子性 ,实现对共享变量的弱一致性保证
  • 对共享变量读之间插入加载屏障,在对共享变量写之后插入存储屏障,来实现volatile语义,实现对共享变量的释放一致性和进入一致性保证
  • 通过存储屏障,禁止初始化操作不重排到构造器结束之后,来实现final的语义
  1. 通过NATIVA方法屏蔽各硬件平台的差异

    不同的硬件底层实现的内存屏障方式不同

    不同硬件底层的硬件指令不同

  2. lazy write

  • 通过存储屏障,将写缓冲区中的数据写回主存,保证可见性和有序性
  • 通过加载屏障,清空无效化队列,保证可见性和有序性
  1. 禁止重排
  • 通过内存屏障,禁止指令重排,实现as-if -serial语义
  • 锁临界区的指令不允许逸出到临界区外
  • 内部锁的释放要在内部锁的申请之后执行
  • 线程中的任何操作都在start方法之后执行
  • 线程中的任何操作都在join方法之前执行
  • 同一个volatile变量读和写之间禁止重排

结束

文章的内容原于个人的理解,有可能和实际会有偏差,如果有发现请指正,以免误导他人。

因为在JMM中大量提到了内存屏障,接下来准备写一篇关于内存屏障的文章

文章聊聊缓存一致性协议中我们提到过,缓存里面最小的单位是缓存行/缓存条目,但是缓存中的具体存储结构是什么样的,缓存行中有存放的是什么?在缓存中是如何寻找指定是还是还存在?本篇我们就聊聊一下Cache Line

缓存中的组构

文章中出现的缓存和Cache,指的是高速缓存,在后面提到的缓存都指的是高速缓存,出现的缓存行指的就是CacheLine

在之前文章中我们只提到了缓存中数据是存储在CacheLine中的,CacheLine是Cache缓存数据的最小单位,但是Cache的结构是怎么样的,CacheLine的结构又是怎么样的,我们先来看一下Cache的整体结构图

由上图可以看出

高速缓存的组成由S个高速缓存组(cache set )

每一组包含E个高速缓存行( cache line),

每一行由一个有效位(valid bit)指明这个行是否包含有意义的信息;一个长度为t的标记位(tag bit ),唯一标识存储在这个高速缓存行中的块在内存中的地址;和一个B字节的数据块(block)构成

高速缓存的结构可以用元组(S,E,B,m )来描述,高速缓存的大小C指的是所有块的大小的和。标记位和有效位不包含在内,因此C=SEB 

块是一个固定大小的信息包,在高速缓存和主存之间来回传送
行是高速缓存中的一个容器,存储块以及其它信息(有效位和标记位)
组是一个或多个行的集合,直接映射高速缓存中的组只由一行组成;组相联和全相联高速缓存中的组是由多个行组成
因为一行总是存储一个块,术语”行”和”块”通常互换使用,我们经常总是说高速缓存的缓存行大小是固定的,缓存行的大小一般为64个字节(不包含有效位和标记位),实际上指的是块大小。这样的叫法十分普遍,只要要以理解块和行之间的区别,就不会造成任何误会

如何查找缓存条目

当从缓存中获取一个数据时,如果缓存行中存在该数据的有效副本,称为缓存命中,否则称为缓存未命中
高速缓存确定一个请求是还命中, 要分为三步选择组、选择行、字抽取
根据每个组的高速缓存行数E,高速缓存被分为不同的类型,在不同的cache映射下,查找查询过程也是不一样的,在讨论之前回顾一下前面的说的高速缓存的结构可以用元组(S,E,B,m )来描述, 里面的m表示有m位表示地址(主存物理地址数),地址的构成如下图

高速缓存的结构将m个地址划分成了

位名称 用途
t位标记位 用来标识当前缓存行数据是还有效
s个组索引 用来定位所访问的地址在高速缓存中所属的组编号
b个偏移块位 访问数据在缓存行中块的偏移量

下面我们分情况讨论一下

直接映射高速缓存

在这种模式下,每个组只有一行(E=1)的高速缓存称为直接映射高速缓存(direct-mapped cache ),它是最容易实现和理解的。它将一个主内存块存储到唯一的一个缓存行中,是一种多对一的映射关系

主内存块块号Y到缓存行行号X的映射函数为
$$
f(X)=Y % s
$$
高速缓存从地址中取出s个组索引位(地址中蓝色区域),去高速缓存中去访问组,由于E=1,一个组内只有一个缓存行,所以只要组内的缓存行设置了有效位,并且高速缓存行中的标记与地址中的标记(绿色区域)相匹配时,那么就缓存命中,根据偏移地址(红色区域)在缓存行中获取对应偏移地址的内容即可

组相关高速缓存

直接映射高速缓存中冲突不命中千万的问题源于每个组只有一行这个限制,组相关高速缓存(set associative cache)放松了这条限制 ,每个组都保存有多于一个的高速缓存行,即1<E<C/B

在这种模式下,高速缓存存在s个组,每组有t个缓存行,主内存数据块映射到哪个组是固定的,但是存储到组内哪一缓存行是不固定的,因为涉及到缓存淘汰策略

设主内存块号为x,那么映射函数为
$$
f(x) = x % s
$$
在这种模式下,组选择的过程和直接映射高速缓存是一样的,使用组索引位(蓝色区域)标识组

组相联高速缓存中的行匹配比直接映射高速缓存中的要复杂,因为每组包含多个缓存行,所以它必须检查组中多个行的标记位和有效位,如果高速缓存行中的某一行的标记位与地址中的标记位(绿色区域)匹配,则缓存命中,然后根据偏移地址(红色区域)在缓存行中获取对应偏移地址的内容即可

如果缓存未命中,则需从主从中读取包含数据的块号,并且替换高速缓存中的一个数据行,至于替换哪个数据行,则根据高速缓存的替换策略来判断,如果替换的数据正是我们下次要用到的,下次在使用到相关的数据时候,则又要替换一次缓存行中的数据,这样无法使用局部性原理,程序执行效率会很差的

全相联高速缓存

该缓存中只有一个分组,一个分组中包含所有的缓存行  (即E=C/B), 主存储块块号可以对应到任意一个缓存行

在这种模式下,组选择非常简单,因为只有一个组,所以默认总是选择组0,地址的结构也会发生变化,因为组索引不用,所以地址中去除了组索引位,地址被划分成了一个标记位和一个块偏移位

行匹配与组相联高速缓存中是一样的,它们之间的区别主要是遍历缓存行规模大小不同,因为构造一个又大双快的相联高速缓存我也爱你困难,而且很昂贵,所以它只适合做小高速缓存。

缓存行的伪共享

缓存工作原理要求它尽量保存最新数据,当从主存向Cache传送一个新块,而Cache中可用位置已被占满时,就会产生Cache替换的问题。

缓存中交换数据的最小单位是缓存行,所以如果一个共享变量被多个cpu核心所使用时,就会出现相同的主内存块号内容被缓存到不同的缓存行中,如果一个cpu核心对它所关心的变量进行了修改,就会导致另一个cpu核心的缓存行失效,产生缓存未命中,如果这样的情况经常发生,会严重的影响程序性能,这就是伪共享**,它会严重浪费系统资源。

如下图,CPUA和CPUB都缓存了相同的数据 ,但是CPUA要对里面的变量A进行修改,CPUB要对变量B进行修改,这时候就产生了伪共享。

其实伪共享产生的本质问题是,不同CPU使用到的数据,被缓存在相同的缓存行中,只要可以解决此问题,就会消除伪共享。

java中如何解决伪共享

java中是通过数据填充的方式,保证变量不会和其他东西同时存在于一个缓存行中,这样失去了地址连续性被加载到同一个缓存行,就避免了伪共享, 当然填充是需要技巧的,需要对缓存行的大小以及操作的数据在内存中的布局有个了解。下面以Java对伪共享的解决为例进行举例。

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.bk.exercise;

/**
* @author BK
* @description: 伪共享测试
* @date 2019-09-02 01:01
*/
public class FalseSharingTest {
//核心数据
private volatile long value;
// cache line padding
private long p1, p2, p3, p4, p5, p6=1L;
}

该对象中定义一个long类型的value是核心数据 ,还有p1-p6 t个Long类型的固定值1,因为value + (p1+..+p6) 是7个Long 类型,一个long类型8字符,总大小为56字节,再加上java中对象头占用的8个字节,刚好一个缓存行的大小64字节,这样就解决了伪共享。这种缓存行填充方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能。

Java7 对伪共享的解决

由于Java7会编译期间会淘汰或者是重新排列无用的字段,所以原来的填充long类型无用字段的办法在Java 7下就失效了,但是伪共享依然会发生,但是将用于填充的long字段写到父类同样可以解决伪共享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.bk.exercise;

/**
* @author BK
* @description: 伪共享测试
* @date 2019-09-02 01:01
*/
public class FalseSharing {
// cache line padding
private long p1, p2, p3, p4, p5, p6=1L;
}
class FalseSharingTest extends FalseSharing{
//核心数据
private volatile long value;
}

Java8 对伪共享的解决

Java8中,Java官方已经提供了对伪共享的解决办法,那就是sun.misc.Contended注解。 有了这个注解解决伪共享就变得简单多了:

1
2
3
4
5
6
7
8
@sun.misc.Contended  
public class FalseSharingTest {
volatile long value = 0L;
}
class FalseSharingTest extends FalseSharing{
//核心数据
private long value;
}

要注意的是直接使用此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended

在程序中利用局部性

理解存储器层次结构本质,对于利用这些知识编辑更有效的程序有很大的帮助,推荐以下技术

  • 将你的注意力集中在内循环上,大部分计算机的内存访问都发生在这里
  • 通过按照数据对象存储在内存中的顺通,以步长为1的来读取数据(减少cache中数据被多次替换),来发挥中的空间局部性
  • 一旦从存储器中读入一个数据对象,就尽量多地使用它,从而使得程序中的时间局部性最大

参考

深入计算机系统 第三版 第6章存储器层次结构

在多线程操作中,可能最经常被提起的就是数据的可见性、原子性、有序性。不管是硬件方面、软件方面都在这三方面做了很足的工作,才能保证程序的正常运行。

之前发表过一篇文章聊聊缓存一致性协议 如果感兴趣的话可以去阅读一下,里面谈到了缓存一致性的实现和处理过程,读完之后可以仔细去细想一下缓存一致性协议到底解决了什么问题。个人理解缓存一致性协议解决了CPU层面的可见性和一致性问题,阅读到这里可以在这里停下来,仔细回想一下缓存一致性的原理,它通过监听共享总线上消息,对自己缓存中的数据修改不同的状态,来保证数据的一致性,对自己缓存中的数据失效后,下次读取会从主存中直接读取最新的数据 ,可以保证可见性,同时保证各缓存中的数据是一致的。

软件的并发编程一样,其实除了可见性、有序性,在计算机指令在执行的过程中,CPU通过不停地切换线程执行,给每个线程分配CPU时间片来实现多线程机制,一定也会存在原子性问题,在计算机层面是怎么解决原子性问题的,这就我们今天要聊的LOCK#指令,有时也被我们称为总线锁。

LOCK指令作用

Intel® 64 and IA-32 Architectures Software Developer’s Manual 中的章节LOCK—Assert LOCK# Signal Prefix 中给出LOCK指令的详细解释

大至翻译之后的意思如下

在CPU的LOCK信号被声明之后,在此期随同执行的指令会转换成原子指令。在多处理器环境中,LOCK信号确保,在此信号被声明之后,处理器独占使用任何共享内存。

在不大多数IA-32和Inter64位处理器中,锁可能在没有LOCK#信号的时情况下发生。请参阅下面的“IA32体系结构兼容性”部分的详细内容。

LOCK前缀只能预加在以下指令前面,并且只能加在这些形式的指令前面,其中目标操作数是内存操作数:add、adc、and、btc、btr、bts、cmpxchg、cmpxch8b,cmpxchg16b,dec,inc,neg,not,or,sbb,sub,xor,xaddxchg

如果LOCK前缀用上述列表中的指令并且源操作数是内存操作数(也就是指令没有对内存进行写操作),可能会出现未定义的操作码异常(ud)。

如果锁前缀与任何不在上面列表中的指令一起使用,也将生成未定义的操作码异常。

xchg指令不管有没有声明LOCK前缀,总是会声明LOCK信号。

锁定前缀通常与BTS指令一起使用,在共享内存环境中,以对内存地址执行读-修改-写操作。

锁定前缀的完整性不受内存字段对齐的影响。对于任意未对齐的字段,可以观察到内存锁定。

此指令的操作在非64位模式和64位模式下是相同的。

从P6系列处理器开始,当使用 LOCK 指令访问的内存已经被处理器加载到缓存中时,LOCK# 信号通常不会断言。取而代之的是,只锁定处理器的缓存。在这里处理器的缓存一致性机制确保对内存进行的操作是原子性的。请参见“锁定操作对内部处理器缓存的影响”,在Intel®64和IA-32体系结构软件开发人员手册第3A卷第8章中,有关锁定缓存的详细信息。

大致翻译差不多如上,核心意思主要说明LOCK指令在声明之后通过锁定总线,独占共享内存,通过一种排它的思想确保当前对内存操作的只有一个线程,然后确定在这段声明期间指令执行不会被打断,来保证其原子性。

处理器如何实现原子操作

首先处理器会保证基本的内存操作的原子性,比如从内存读取或者写入一个字节是原子的,但对于读-改-写、或者是其它复杂的内存操作是不能保证其原子性的,又比如跨总线宽度跨多个缓存行夸页表的访问,这时候需要处理器提供总线锁缓存锁两个机制来保证复杂的内存操作原子性

总线锁

LOCK#信号就是我们经常说到的总线锁,处理器使用**LOCK#**信号达到锁定总线,来解决原子性问题,当一个处理器往总线上输出LOCK#信号时,其它处理器的请求将被阻塞,此时该处理器此时独占共享内存。

总线锁这种做法锁定的范围太大了,导致CPU利用率急剧下降,因为使用LOCK#是把CPU和内存之间的通信锁住了,这使得锁定时期间,其它处理器不能操作其内存地址的数据 ,所以总线锁的开销比较大。

缓存锁

如果访问的内存区域已经缓存在处理器的缓存行中,P6系统和之后系列的处理器则不会声明LOCK#信号,它会对CPU的缓存中的缓存行进行锁定,在锁定期间,其它 CPU 不能同时缓存此数据,在修改之后,通过缓存一致性协议来保证修改的原子性,这个操作被称为**”缓存锁”**

什么情况下使用总线锁(LOCK#)

当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,也会使用总线锁
因为从P6系列处理器开始才有缓存锁,所以对于早些处理器是不支持缓存锁定的,也会使用总线锁

有些指令自带总线锁

BTS、BTR、BTC 、XADD、CMPXCHG、ADD、OR等,这些指令操作的内存区域就会加锁,导致其它处理器不能同时访问它。

在上面指令中的CMPXCHG就是JAVA里面CAS底层常用的指令,这个指令在执行的时候,会自动加总线锁保,导致其它 处理器不能同时访问,证其原子性。

LOCK#作用总结

  1. 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,因为锁总线的开销比较大,后来的处理器都采用锁缓存替代锁总线,在无法使用缓存锁的时候会降级使用总线锁
  2. lock期间的写操作会回写已修改的数据到主内存,同时通过缓存一致性协议让其它CPU相关缓存行失效

写在最后

总线锁缓存锁可以保证原子性缓存一致性协议可以保证可见性,那么JAVA中的内存模型,它做了些什么?下一篇聊聊JAVA中的内存模型(JMM)聊聊JMM

0%