Gitlab CICD本文讲一下如何使用gitlab来做CICD：使用gitlab做代码仓库，同时用gitlab的CI功能完成代码扫描、编译打包、镜像构建和镜像中心，并使用k8s来做部署。我们的流程是先使用gitlab来做完整的流程，一步步的由简到繁，一点点增加功能和优化过程，致力于达到真实gitlab企业的CICD效果。优化内容包括但不限于使用nexus、harbor等搭建私库优化CI的构建效率，使用k8snamespace做多环境隔离，并通过ConfigMap和Secret存储环境变量等。整个CICD均采用GitOps的一切皆代码 (Everything as Code)思想。Git作为一切的来源，这代表除了代码本身，CI流水线要代码化、k8s的部署要代码化、k8s环境变量配置也要代码化。整个测试将使用四台服务器，服务器操作系统均为Debian12192.168.31.193 作为gitlab、gitlab-runner以及后续的jenkins、habor等部署192.168.31.240、192.168.31.66、192.168.31.97 三个k8s节点，为服务做到多节点部署项目所用gitlab版本为最新的18.1.1-ce.0项目完整代码和配置可见：coderZoe/cicd-test-project1. Gitlab CI1.1 Gitlab部署services: gitlab: image: gitlab/gitlab-ce:18.1.1-ce.0 container_name: gitlab restart: always hostname: 'gitlab.example.com' environment: GITLAB_OMNIBUS_CONFIG: | external_url 'http://192.168.31.193' registry_external_url 'http://192.168.31.193:5050' ports: - '80:80' - '443:443' - '22:22' # 镜像中心的端口 - '5050:5050' volumes: - '/home/gitlab/config:/etc/gitlab' - '/home/gitlab/logs:/var/log/gitlab' - '/home/gitlab/data:/var/opt/gitlab' shm_size: '1024m'注意，这里gitlab使用了ssh的22端口和http与https的80与443端口，且我直接映射出了，因此宿主机不能占用这些端口，如果宿主机占用了，可以修改gitlab所用端口，见Install GitLab in a Docker container | GitLab Docs1.2 创建项目登录gitlab后，创建一个group并在该group下创建一个项目：1.3 创建runnergitlab的runner负责执行具体的CICD任务，我们创建一个runner并将其分配到上面的test-group下。首先修改docker-compose.yml，创建一个runner：services: gitlab: image: gitlab/gitlab-ce:18.1.1-ce.0 container_name: gitlab restart: always hostname: 'gitlab.example.com' environment: GITLAB_OMNIBUS_CONFIG: | external_url 'http://192.168.31.193' registry_external_url 'http://192.168.31.193:5050' ports: - '80:80' - '443:443' - '22:22' - '5050:5050' volumes: - '/home/gitlab/config:/etc/gitlab' - '/home/gitlab/logs:/var/log/gitlab' - '/home/gitlab/data:/var/opt/gitlab' shm_size: '1024m' # gitlab runner gitlab-runner: image: 'gitlab/gitlab-runner:v18.1.1' container_name: gitlab-runner restart: always volumes: # runner的配置 - '/home/gitlab/runner:/etc/gitlab-runner' # 将宿主机的docker server映射出来，方便runner可以操作宿主机docker - '/var/run/docker.sock:/var/run/docker.sock'然后在test-group下创建runner：最后按照上述说明终端执行注册runner的指令root@debian-4:/home/gitlab# docker exec -it gitlab-runner gitlab-runner register --url http://192.168.31.193 --token glrt-QtNXZWzWVgcHWLUWvfgvoGc6MwpvOjEKdDoyCnU6MQ8.01.171ovfeqe Runtime platform arch=amd64 os=linux pid=19 revision=2b813ade version=18.1.1 Running in system-mode. Enter the GitLab instance URL (for example, https://gitlab.com/): [http://192.168.31.193]: Verifying runner... is valid correlation_id=01JZQAERWE229FYN4J8TYQ512F runner=QtNXZWzWV Enter a name for the runner. This is stored only in the local config.toml file: [7dd12e268d62]: Enter an executor: docker, docker-windows, docker+machine, kubernetes, shell, parallels, docker-autoscaler, instance, custom, ssh, virtualbox: docker Enter the default Docker image (for example, ruby:2.7): alpine:latest Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded! Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml" 注册完成后回到页面，可以看到runner正常运行了：1.4 创建简单的CI我们以一个Java SpringBoot项目为例创建一个简单的CI流程，流程为当代码push到gitlab后项目自动使用maven编译打包并根据Dockerfile构建Docker镜像。首先我们需要对gitlab-runner和docker做一些改动修改gitlab-runner的config.toml，将docker.sock映射出来，方便gitlab-runner可以直接操作宿主机的docker修改gitlab-runner的config.toml，镜像拉取策略为if-not-present而非always，避免每次CI时都拉取镜像由于我们使用的是gitlab自身的镜像注册中心，路径为http://192.168.31.193:5050，内网环境非HTTPS，因此需要配置允许非安全访问项目.gitlab-ci.ymlstages: - build - publish variables: IMAGE_NAME: $CI_REGISTRY/$CI_PROJECT_PATH IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" cache: key: "$CI_PROJECT_PATH_SLUG-maven" paths: - .m2/repository/ build-job: stage: build tags: - docker - linux image: maven:3.9.6-eclipse-temurin-21 script: - echo "Java version check:" - java -version - echo "Maven version check:" - mvn -v - echo "Compiling, testing, and packaging the application ..." - mvn package -B artifacts: paths: - target/ expire_in: 1 hour publish-job: stage: publish image: docker:latest tags: - docker - linux script: - echo "Logging into GitLab Container Registry:" "$CI_REGISTRY" - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY - echo "Building Docker image with name:" "$IMAGE_NAME:$IMAGE_TAG" - docker build -t "$IMAGE_NAME:$IMAGE_TAG" . - echo "Pushing Docker image...." - docker push "$IMAGE_NAME:$IMAGE_TAG" needs: - job: build-job artifacts: true上述CI流程比较简单，分为build和publish两个阶段。build阶段使用maven镜像通过mvn package -B来编译打包项目，并将/target下的产物交给publish流程。publish使用docker镜像并结合当前的Dockerfile构建服务镜像，并将服务镜像上传到镜像中心。项目DockerfileFROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]当我们commit并push项目的时候，会自动触发pipeline并执行上述两个Job，执行结果如下：上述.gitlab-ci.yml配置了缓存，缓存的key是项目名，缓存的内容是本地maven仓库，这主要是为了避免每次build的时候都需要从中央仓库拉取依赖然后build，很多时候之前拉取过的依赖是可以直接用的。这有些类似于我们本地的maven仓库，所有服务一般都使用本地同一份maven仓库，而不是每个服务一份。但这里我设置的key是项目名，而非一个全局常量，我看网上用全局缓存的也很多，这样所有服务所有分支每次打包都使用同一份maven仓库，理论复用更高，但考虑到我们项目比较少，且避免SNAPSHOT 依赖造成的冲突问题，还是采取了项目名级别的maven缓存。2. CDgitlab集成k8s，实现持续部署，k8s集群搭建可见附录。由于我们这里镜像仓库采用的是gitlab的192.168.31.193:5050，k8s containerd从这镜像中心拉取镜像的时候也需要配置允许非安全访问，在三台k8s master上执行：sudo sed -i '/\[plugins."io.containerd.grpc.v1.cri".registry\]/a\ [plugins."io.containerd.grpc.v1.cri".registry.mirrors."192.168.31.193:5050"]\ endpoint = ["http://192.168.31.193:5050"]' /etc/containerd/config.toml创建k8s凭证# 创建namespace kubectl create namespace my-app # 创建账户 kubectl create serviceaccount gitlab-ci-sa -n my-app # 为账户绑定角色 kubectl create rolebinding gitlab-ci-rb -n my-app --clusterrole=edit --serviceaccount=my-app:gitlab-ci-sa执行如下脚本获取账户凭证#!/bin/bash NAMESPACE="my-app" SA_NAME="gitlab-ci-sa" CLUSTER_NAME="default-cluster" SERVER_URL=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') CA_DATA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') TOKEN=$(kubectl create token ${SA_NAME} --namespace ${NAMESPACE}) echo " apiVersion: v1 kind: Config clusters: - name: ${CLUSTER_NAME} cluster: server: ${SERVER_URL} certificate-authority-data: ${CA_DATA} contexts: - name: ${CLUSTER_NAME} context: cluster: ${CLUSTER_NAME} user: ${SA_NAME} namespace: ${NAMESPACE} current-context: ${CLUSTER_NAME} users: - name: ${SA_NAME} user: token: ${TOKEN} "将打印内容复制到gitlab：为项目创建deployment和service，在项目根目录创建k8s目录，并创建deployment.yaml和service.yaml在k8s目录下deploymentapiVersion: apps/v1 kind: Deployment metadata: name: cicd-test-project-deployment labels: app: cicd-test-project spec: replicas: 3 selector: matchLabels: app: cicd-test-project template: metadata: labels: app: cicd-test-project spec: containers: - name: cicd-test-project-container image: IMAGE_PLACEHOLDER # 占位符 ports: - containerPort: 8080 serviceapiVersion: v1 kind: Service metadata: name: cicd-test-project-service spec: type: NodePort selector: app: cicd-test-project ports: - protocol: TCP port: 80 targetPort: 8080 nodePort: 30080修改.gitlab-ci.yml增加部署流程：stages: - build - publish - deploy variables: IMAGE_NAME: $CI_REGISTRY/$CI_PROJECT_PATH IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" cache: key: "$CI_PROJECT_PATH_SLUG-maven" paths: - .m2/repository/ build-job: stage: build tags: - docker - linux image: maven:3.9.6-eclipse-temurin-21 script: - echo "Java version check:" - java -version - echo "Maven version check:" - mvn -v - echo "Compiling, testing, and packaging the application ..." - mvn package -B artifacts: paths: - target/ expire_in: 1 hour publish-job: stage: publish image: docker:latest tags: - docker - linux script: - echo "Logging into GitLab Container Registry:" "$CI_REGISTRY" "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY - echo "Building Docker image with name:" "$IMAGE_NAME:$IMAGE_TAG" - docker build -t "$IMAGE_NAME:$IMAGE_TAG" . - echo "Pushing Docker image...." - docker push "$IMAGE_NAME:$IMAGE_TAG" needs: - job: build-job artifacts: true deploy-job: stage: deploy image: name: bitnami/kubectl:latest entrypoint: [""] tags: - docker - linux script: - echo "Deploying to Kubernetes in namespace 'my-app'..." - export KUBECONFIG=$KUBE_CONFIG - sed -i "s|IMAGE_PLACEHOLDER|$IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - kubectl apply -f k8s/deployment.yaml - kubectl apply -f k8s/service.yaml - echo "Waiting for deployment to complete..." - kubectl rollout status deployment/cicd-test-project-deployment -n my-app - echo "Deployment successful!" needs: - publish-job代码push后：在k8s节点上查看：访问http://192.168.31.100:30080/hello如上我们就完成了一个简单的CICD流程，打通了从代码到gitlab仓库再到gitlab-runner和k8s等环节，实现了自动编译打包、构建镜像和自动部署功能。3. Git Flow3.1 Git Flow 流程我们现在丰富上述CICD流程，增加质量门禁、单元测试与多环境部署的功能，丰富后的CICD流程如下：Stage 1: build (编译打包阶段)build-and-package (编译、单元测试和打包):运行mvn verify -B对源码进行编译、单元测试和打包质量门禁：要求代码覆盖率不低于80%（可配置），否则流水线失败。测试报告（如Jacoco）将被发布供审查Stage 2: test-and-analyze (扫描分析阶段)code-quality-scan (静态分析):通过SonarQube Scanner进行代码质量扫描。质量门禁: 如果代码异味、Bugs或漏洞超过预设阈值（如"Blocker"级别问题 > 0），流水线失败。Stage 3: publish (镜像构建和发布阶段)publish-image(构建镜像)：使用公司标准化的Dockerfile模板构建Docker镜像。该模板将包含多阶段构建（Multi-stage builds）优化，以生成最小化的生产镜像（如使用Distroless或Alpine作为基础镜像）。镜像Tag将包含Git Commit SHA和流水线ID，确保唯一性和可追溯性（例如：myapp:1.2.0-a1b2c3d-12345）构建完成将镜像推送到镜像仓库Stage 4: deploy-dev (部署到开发环境)通过kubectl 使用gitlab环境变量里配置的开发环境的 KUBE CONFIG信息，并结合项目的deployment和service等文件实现自动部署到开发环境，方便开发人员的自测验证Stage 5: deploy-relese (部署到预发布环境)通过kubectl 使用gitlab环境变量里配置的预发布环境的 KUBE CONFIG信息，并结合项目的deployment和service等文件实现自动部署到预发布环境，方便测试人员测试Stage 6: deploy-prod (部署到生产环境)通过kubectl 使用gitlab环境变量里配置的生产环境的 KUBE CONFIG信息，并结合项目的deployment和service等文件实现手动部署到生产环境代码版本管理常见的工作流是Git Flow与Github Flow，以笔者多年经验 Git Flow是比较合理且完善的一种方案，我们就以Git Flow为例来模仿真实的企业版本管理与CICD流程。Git Flow核心CICD流程如下：项目存在两个长周期的分支分别是develop和master，开发人员需要开发新功能的时候从最新的develop分支创建自己的feature分支。开发人员可以正常在feature分支上push代码，每次push后都会自动触发上述Stage1。等开发人员开发完成当前迭代的需求后，将feature分支代码MR到develop分支，一旦发起向develop分支的MR：运行成本较低的验证性流水线（Stage 1和Stage 2），为Code Review提供决策依据（代码质量、安全性、测试覆盖率是否达标）。如果验证不通过，MR应被阻止合并。MR被批准并合并后： 在目标分支（develop）上，触发一次完整的、包含部署的流水线。此时，部署到开发环境(Stage 1、Stage 2、Stage 3和Stage 4)。所有开发人员开发完当前迭代分支且都MR到develop后，需要转测当前迭代的需求给测试人员测试，因此创建一个develop向release的MR：运行成本较低的验证性流水线（Stage 1和Stage 2），为Code Review提供决策依据（代码质量、安全性、测试覆盖率是否达标）。如果验证不通过，MR应被阻止合并。MR被批准并合并后： 在目标分支（release）上，触发一次完整的、包含部署的流水线。此时，部署到预发布环境(Stage 1、Stage 2、Stage 3和Stage 5)。当测试人员测试完当前迭代没有问题后，准备发版部署到生产环境，创建一个release向master的MR：运行成本较低的验证性流水线（Stage 1和Stage 2），为Code Review提供决策依据（代码质量、安全性、测试覆盖率是否达标）。如果验证不通过，MR应被阻止合并。MR被批准并合并后： 在目标分支（master）上，触发一次完整的、包含部署的流水线。此时，部署到生产环境(Stage 1、Stage 2、Stage 3和Stage 6)。3.2 Git Flow 实战3.2.1 设置保护分支首先我们先设置项目的保护分支：master、develop和release只允许merge不允许push3.2.2 部署sonarqube和集成gitlabversion: "3" services: sonarqube: image: sonarqube:lts-community # 我们使用长期支持的社区版 container_name: sonarqube ports: - "9000:9000" environment: - SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true # 禁用Elasticsearch的启动检查，便于开发环境启动 volumes: - sonarqube_data:/opt/sonarqube/data - sonarqube_extensions:/opt/sonarqube/extensions - sonarqube_logs:/opt/sonarqube/logs volumes: sonarqube_data: sonarqube_extensions: sonarqube_logs:上述sonarqube用的是内嵌数据块，仅适合测试，在生产环境请切为其他数据块（如postgresql）在sonarqube点击 配置=> ALM集成=> Gitlab输入gitlab API网址和token：其中token获取方式：点击自己gitlab头像 => Preferences => Access tokens => Add new token，添加token，把权限都加上即可（也可以按需加，我图省事都加上了）。然后再回到sonarqube点击项目 => 添加项目 => 来自Gitlab，选择具体的项目点击设置即可点击配置分析器 => 创建令牌将sonarqube的信息与gitlab-ci集成：在Gitlab的cicd-test-group下配置token：3.2.3 配置k8s环境变量一般来说不同环境使用不同的k8s集群，我们需要先在项目中添加三组环境变量然后添加k8s环境变量，添加方式与之前CD一章一样，只是这里要三个环境添加各自的k8s信息：创建一个永久token：# gitlab-ci-secret.yaml apiVersion: v1 kind: Secret metadata: name: gitlab-ci-secret namespace: my-app annotations: kubernetes.io/service-account.name: gitlab-ci-sa type: kubernetes.io/service-account-tokenkubectl apply -f gitlab-ci-secret.yaml使用脚本提取token：#!/bin/bash # --- 1. 设置变量 --- NAMESPACE="my-app" SA_NAME="gitlab-ci-sa" SECRET_NAME="gitlab-ci-secret" # 我们创建的Secret的名字 CLUSTER_NAME="default-cluster" # --- 2. 自动获取集群信息 --- SERVER_URL=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') CA_DATA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') # --- 3. 从Secret中提取永久Token (关键变更) --- # 这条命令会解码(decode)Secret中的数据，并提取出token字段 TOKEN=$(kubectl get secret ${SECRET_NAME} --namespace ${NAMESPACE} -o jsonpath='{.data.token}' | base64 --decode) # --- 4. 生成最终的kubeconfig文件内容并打印到屏幕 --- echo " apiVersion: v1 kind: Config clusters: - name: ${CLUSTER_NAME} cluster: server: ${SERVER_URL} certificate-authority-data: ${CA_DATA} contexts: - name: ${CLUSTER_NAME} context: cluster: ${CLUSTER_NAME} user: ${SA_NAME} namespace: ${NAMESPACE} current-context: ${CLUSTER_NAME} users: - name: ${SA_NAME} user: token: ${TOKEN} "chmod +x generate-kubeconfig.sh ./generate-kubeconfig.sh将不同环境生成的结果拷贝进gitlab的variables，选择对应的环境，key是KUBE_CONFIG，value就是脚本生成结果。3.2.4 编写CI# ============================================================================== # 全局默认设置 (Global Defaults) # ============================================================================== default: tags: - linux - docker variables: IMAGE_NAME: $CI_REGISTRY_IMAGE IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" .maven_cache: cache: key: files: - pom.xml prefix: ${CI_PROJECT_NAME} paths: - .m2/repository/ .maven_defaults: extends: - .maven_cache image: maven:3.9.6-eclipse-temurin-21 .kubectl_defaults: image: name: bitnami/kubectl:latest entrypoint: [""] stages: - build - test-and-analyze - publish - deploy-dev - deploy-release - deploy-prod build-and-test: stage: build extends: .maven_defaults script: - echo "INFO [Stage build] Compiling, running unit tests, and packaging..." - mvn verify -B artifacts: paths: - target/ expire_in: 1 hour rules: - if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"' code-quality-scan: stage: test-and-analyze extends: .maven_defaults variables: GIT_DEPTH: "0" script: - echo "INFO [Stage test-and-analyze] Running SonarQube analysis via automatic integration..." - >- mvn verify sonar:sonar -Dsonar.projectKey=test-group_cicd-test-project_AZgXyvpJjZI_rh9cYAvb -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 needs: - job: build-and-test artifacts: true allow_failure: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' publish-image: stage: publish image: docker:latest script: - echo "INFO [Stage publish] Logging into GitLab Registry $CI_REGISTRY..." - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY - echo "INFO Building Docker image with name $IMAGE_NAME:$IMAGE_TAG" - docker build -t "$IMAGE_NAME:$IMAGE_TAG" . - echo "INFO Pushing Docker image $IMAGE_NAME:$IMAGE_TAG" - docker push "$IMAGE_NAME:$IMAGE_TAG" needs: - job: code-quality-scan artifacts: false - job: build-and-test artifacts: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' .deploy_template: extends: .kubectl_defaults script: - echo "INFO Deploying image $IMAGE_NAME:$IMAGE_TAG to $CI_ENVIRONMENT_NAME environment..." - export KUBECONFIG=$KUBE_CONFIG - cat $KUBE_CONFIG - sed -i "s|IMAGE_PLACEHOLDER|$IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - echo "INFO Applying Kubernetes manifests..." - kubectl apply -f k8s/deployment.yaml - kubectl apply -f k8s/service.yaml - echo "INFO Waiting for deployment rollout to complete..." - kubectl rollout status deployment/cicd-test-project-deployment --timeout=120s needs: - publish-image deploy-dev: stage: deploy-dev extends: .deploy_template environment: name: develop url: https://dev.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "develop"' deploy-release: stage: deploy-release extends: .deploy_template environment: name: release url: https://staging.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "release"' deploy-prod: stage: deploy-prod extends: .deploy_template environment: name: prod url: https://www.your-app.com when: manual allow_failure: false rules: - if: '$CI_COMMIT_BRANCH == "master"'3.2.5 MR测试CI编写完成后，发起一个feature/v1到develop的MRMR一旦发起，立马在source branch(feature/v1)执行打包和代码扫描打包和代码扫描通过后，相关人员可以同意Merge一旦同意Merge，代码被push到develop后，触发一次完整的CICD，包括打包、代码审查、镜像构建以及部署到开发环境从develop发起向release的MR：可以看到同样的流程，走CICD，并部署release分支到预发布的k8s集群上。从release发起向master的MR：这里特殊的是我们生产环境的部署采用手动模式，因此需要手动确认下。4. CI优化4.1 私有maven仓库4.1.1 搭建nexus一般企业开发都会有自己的私有maven仓库，这有非常多好处，其中一个就是我们CICD的时候可以不从maven center拉取依赖，避免网络不通、下载过慢等问题。搭建私服maven仓库一般选用nexus3version: '3.8' services: nexus: image: sonatype/nexus3 container_name: nexus restart: always ports: - "8081:8081" volumes: - "/home/nexus/data:/nexus-data" environment: - INSTALL4J_ADD_VM_PARAMS=-Xms1024m -Xmx2048m -XX:MaxDirectMemorySize=2048m -Djava.util.prefs.userRoot=/nexus-data/prefs启动后会生成一个admin的临时密码登录nexus，按引导修改密码并做一些简单配置nexus基本可以做到开箱即用，无需额外配置。因为已经配置好了默认的blob，proxy repository，host repository和group repository，我们直接拿来用即可，如果需要，你也可以加一些别的代理库如aliyun的maven镜像源。4.1.2 修改CI在项目的根目录下新建.mvn/settings.xml文件<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> <mirrors> <mirror> <id>nexus-public</id> <mirrorOf>*</mirrorOf> <url>http://192.168.31.193:8081/repository/maven-public/</url> </mirror> </mirrors> </settings>修改.gitlab-ci.yml，主要改动使用我们给的上面这个settings.xml来启动maven，移除maven缓存：# ============================================================================== # 全局默认设置 (Global Defaults) # ============================================================================== default: tags: - linux - docker variables: IMAGE_NAME: $CI_REGISTRY_IMAGE IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA .maven_defaults: image: maven:3.9.6-eclipse-temurin-21 .kubectl_defaults: image: name: bitnami/kubectl:latest entrypoint: [""] stages: - build - test-and-analyze - publish - deploy-dev - deploy-release - deploy-prod # ============================================================================== # STAGE: build - Compiles, runs unit tests, and packages the application. # ============================================================================== build-and-test: stage: build extends: .maven_defaults script: - echo "INFO [Stage build] Compiling, running unit tests, and packaging..." # 主要改动引入我们的settings.xml - mvn -s .mvn/settings.xml verify artifacts: paths: - target/ expire_in: 1 hour rules: - if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: test-and-analyze - Performs SonarQube quality gate check. # ============================================================================== code-quality-scan: stage: test-and-analyze extends: .maven_defaults variables: GIT_DEPTH: "0" script: - echo "INFO [Stage test-and-analyze] Running SonarQube analysis via automatic integration..."！ - >- mvn -s .mvn/settings.xml verify sonar:sonar -Dsonar.projectKey=test-group_cicd-test-project_AZgXyvpJjZI_rh9cYAvb -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 needs: - job: build-and-test artifacts: true allow_failure: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: publish - Builds and pushes a Docker image to the registry. # ============================================================================== publish-image: stage: publish image: docker:latest script: - echo "INFO [Stage publish] Logging into GitLab Registry $CI_REGISTRY..." - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY - echo "INFO Building Docker image with name $IMAGE_NAME:$IMAGE_TAG" - docker build -t "$IMAGE_NAME:$IMAGE_TAG" . - echo "INFO Pushing Docker image $IMAGE_NAME:$IMAGE_TAG" - docker push "$IMAGE_NAME:$IMAGE_TAG" needs: - job: code-quality-scan artifacts: false - job: build-and-test artifacts: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' # ============================================================================== # STAGE: deploy-* # ============================================================================== .deploy_template: extends: .kubectl_defaults script: - echo "INFO Deploying image $IMAGE_NAME:$IMAGE_TAG to $CI_ENVIRONMENT_NAME environment..." - export KUBECONFIG=$KUBE_CONFIG - cat $KUBE_CONFIG - sed -i "s|IMAGE_PLACEHOLDER|$IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - echo "INFO Applying Kubernetes manifests..." - kubectl apply -f k8s/deployment.yaml - kubectl apply -f k8s/service.yaml - echo "INFO Waiting for deployment rollout to complete..." - kubectl rollout status deployment/cicd-test-project-deployment --timeout=120s needs: - publish-image deploy-dev: stage: deploy-dev extends: .deploy_template environment: name: develop url: https://dev.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "develop"' deploy-release: stage: deploy-release extends: .deploy_template environment: name: release url: https://staging.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "release"' deploy-prod: stage: deploy-prod extends: .deploy_template environment: name: prod url: https://www.your-app.com when: manual allow_failure: false rules: - if: '$CI_COMMIT_BRANCH == "master"'上述CI在使用maven打包的时候会使用我们在settings.xml中配置的镜像地址（也即我们搭建的nexus私服maven仓库），如果nexus中存在依赖则直接返回给CI，如果不存在，则去代理地址仓库拉取依赖，代理仓库有代理地址，会去代理地址拉取。4.2 私有镜像仓库理论上gitlab已经内置了镜像仓库(5050端口就是镜像仓库端口)，所以其实我们完全可以直接用gitlab的镜像仓库。但有时候为了解耦或者一些企业化管理需求，如更高的安全权限以及漏洞扫描镜像签名等功能。这种时候就需要用到harbor。4.2.1 Harbor安装跟着官方文档，按安装教程安装harbor即可。安装完成后创建项目与对应的账户：4.2.2 集成进gitlab将harbor集成进gitlab：项目或group下 => Setting => integrations => Habor，集成harbor。 如果你的harbor地址是内网地址，还需要先授权本地网络访问：Admin => Settings => Network => Outbound requests勾选：Allow requests to the local network from webhooks and integrations 4.2.3 修改Docker和k8s containerd配置由于我们的镜像中心换为了harbor不再是gitlab-registry，且harbor部署的时候我未使用https，因此我们需要修改部署gitlab宿主机的docker配置和k8s节点containerd配置：Docker增加非安全注册中心三个k8s节点均执行如下脚本：sudo sed -i '/\[plugins."io.containerd.grpc.v1.cri".registry\]/a\ [plugins."io.containerd.grpc.v1.cri".registry.mirrors."192.168.31.193:20080"]\ endpoint = ["http://192.168.31.193:20080"]' /etc/containerd/config.toml4.2.4 修改CI# ============================================================================== # 全局默认设置 (Global Defaults) # ============================================================================== default: tags: - linux - docker variables: IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA .maven_defaults: image: maven:3.9.6-eclipse-temurin-21 .kubectl_defaults: image: name: bitnami/kubectl:latest entrypoint: [""] # 生成镜像名，由于源gitlab-regisry可以直接通过$CI_REGISTRY_IMAGE拿到，但harbor没有，因此我们需要自己拼接 .prepare_image_name: &prepare_image_name - export HARBOR_HOST_WITH_PORT=$(echo $HARBOR_URL | sed -E 's|https?://||; s|/+$||') - export FULL_IMAGE_NAME="$HARBOR_HOST_WITH_PORT/$HARBOR_PROJECT/$CI_PROJECT_NAME" stages: - build - test-and-analyze - publish - deploy-dev - deploy-release - deploy-prod # ============================================================================== # STAGE: build - Compiles, runs unit tests, and packages the application. # ============================================================================== build-and-test: stage: build extends: .maven_defaults script: - echo "INFO [Stage build] Compiling, running unit tests, and packaging..." - mvn -s .mvn/settings.xml verify artifacts: paths: - target/ expire_in: 1 hour rules: - if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: test-and-analyze - Performs SonarQube quality gate check. # ============================================================================== code-quality-scan: stage: test-and-analyze extends: .maven_defaults variables: GIT_DEPTH: "0" script: - echo "INFO [Stage test-and-analyze] Running SonarQube analysis via automatic integration..." - >- mvn -s .mvn/settings.xml verify sonar:sonar -Dsonar.projectKey=test-group_cicd-test-project_AZgXyvpJjZI_rh9cYAvb -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 needs: - job: build-and-test artifacts: true allow_failure: true rules: # 向关键分支合并或推送时进行强制的质量门禁检查 - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' # 在合并请求时运行，以便在合并前发现问题 - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: publish - Builds and pushes a Docker image to the registry. # ============================================================================== publish-image: stage: publish image: docker:latest before_script: - *prepare_image_name # 关键改动，引用锚点，获得完整镜像名 script: - echo "INFO [Stage publish] Logging into Harbor $HARBOR_URL" - echo "$HARBOR_PASSWORD" | docker login -u "$HARBOR_USERNAME" --password-stdin $HARBOR_URL # 下面均使用$FULL_IMAGE_NAME - echo "INFO Building Docker image with name $FULL_IMAGE_NAME:$IMAGE_TAG" - docker build -t "$FULL_IMAGE_NAME:$IMAGE_TAG" . - echo "INFO Pushing Docker image $FULL_IMAGE_NAME:$IMAGE_TAG to Harbor" - docker push "$FULL_IMAGE_NAME:$IMAGE_TAG" needs: - job: code-quality-scan artifacts: false - job: build-and-test artifacts: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' # ============================================================================== # STAGE: deploy-* # ============================================================================== .deploy_template: extends: .kubectl_defaults before_script: - *prepare_image_name # 关键改动，引用锚点，获得完整镜像名 script: - echo "INFO Deploying image $IMAGE_NAME:$IMAGE_TAG to $CI_ENVIRONMENT_NAME environment..." - export KUBECONFIG=$KUBE_CONFIG # 关键改动，使用$FULL_IMAGE_NAME - sed -i "s|IMAGE_PLACEHOLDER|$FULL_IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - echo "INFO Applying Kubernetes manifests..." - kubectl apply -f k8s/deployment.yaml - kubectl apply -f k8s/service.yaml - echo "INFO Waiting for deployment rollout to complete..." - kubectl rollout status deployment/cicd-test-project-deployment --timeout=120s needs: - publish-image deploy-dev: stage: deploy-dev extends: .deploy_template environment: name: develop url: https://dev.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "develop"' deploy-release: stage: deploy-release extends: .deploy_template environment: name: release url: https://staging.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "release"' deploy-prod: stage: deploy-prod extends: .deploy_template environment: name: prod url: https://www.your-app.com when: manual allow_failure: false rules: - if: '$CI_COMMIT_BRANCH == "master"'执行一遍完整CI：4.2.5 镜像代理既然我们有了harbor，肯定想的是不仅用来存储自己构建的镜像，还想缓存第三方的镜像。比如我们CI的时候用到了maven:3.9.6-eclipse-temurin-21，Dockerfile用到了eclipse-temurin:21-jre-alpine。理论上我们配置了gitlab-runner的镜像拉取策略是pull_policy = "if-not-present"，宿主机只需要拉取一次镜像存到本地后续无需再拉取。但假设我们清理了宿主机上的镜像或Gitlab从宿主机上做了迁移，亦或别的平台也想用这些镜像，这时候我们本地缓存的镜像就失效了，还得重新拉取。但我们知道国内访问dockrhub等官方镜像源有些困难，因此一个比较好的方案是使用harbor做镜像代理，缓存我们需要的第三方镜像，CI或Dockerfile直接去harbor中拉取这些镜像即可。与nexus类似，如果本地存储的有镜像，直接将镜像返回给请求源，如果本地没有镜像则去代理源去下载。首先我们点击harbor的系统管理=> 仓库管理 => 新建目标，创建一个代理，这里我选择的是（注国内无法访问，需自己在harbor的配置中设置http_proxy和https_proxy）。配置好代理镜像源后我们新建一个项目，我这里命名为docker-proxy并开启镜像代理，选择我们刚才建的dockerhub-proxy修改我们的.gitlab-ci.yaml文件以及修改Dockerfile：default: tags: - linux - docker variables: IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA .maven_defaults: # 主要改动，修改镜像源 image: 192.168.31.193:20080/docker-proxy/maven:3.9.6-eclipse-temurin-21 .kubectl_defaults: image: # 主要改动，修改镜像源 name: 192.168.31.193:20080/docker-proxy/bitnami/kubectl:latest entrypoint: [""] .prepare_image_name: &prepare_image_name - export HARBOR_HOST_WITH_PORT=$(echo $HARBOR_URL | sed -E 's|https?://||; s|/+$||') - export FULL_IMAGE_NAME="$HARBOR_HOST_WITH_PORT/$HARBOR_PROJECT/$CI_PROJECT_NAME" stages: - build - test-and-analyze - publish - deploy-dev - deploy-release - deploy-prod # ============================================================================== # STAGE: build - Compiles, runs unit tests, and packages the application. # ============================================================================== build-and-test: stage: build extends: .maven_defaults script: - echo "INFO [Stage build] Compiling, running unit tests, and packaging..." - mvn -s .mvn/settings.xml verify artifacts: paths: - target/ expire_in: 1 hour rules: - if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: test-and-analyze - Performs SonarQube quality gate check. # ============================================================================== code-quality-scan: stage: test-and-analyze extends: .maven_defaults variables: GIT_DEPTH: "0" script: - echo "INFO [Stage test-and-analyze] Running SonarQube analysis via automatic integration..." - >- mvn -s .mvn/settings.xml verify sonar:sonar -Dsonar.projectKey=test-group_cicd-test-project_AZgXyvpJjZI_rh9cYAvb -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 needs: - job: build-and-test artifacts: true allow_failure: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: publish - Builds and pushes a Docker image to the registry. # ============================================================================== publish-image: stage: publish # 主要改动，修改镜像源 image: 192.168.31.193:20080/docker-proxy/docker:latest before_script: - *prepare_image_name script: - echo "INFO [Stage publish] Logging into Harbor $HARBOR_URL" - echo "$HARBOR_PASSWORD" | docker login -u "$HARBOR_USERNAME" --password-stdin $HARBOR_URL - echo "INFO Building Docker image with name $FULL_IMAGE_NAME:$IMAGE_TAG" - docker build -t "$FULL_IMAGE_NAME:$IMAGE_TAG" . - echo "INFO Pushing Docker image $FULL_IMAGE_NAME:$IMAGE_TAG to Harbor" - docker push "$FULL_IMAGE_NAME:$IMAGE_TAG" needs: - job: code-quality-scan artifacts: false - job: build-and-test artifacts: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' # ============================================================================== # STAGE: deploy-* # ============================================================================== .deploy_template: extends: .kubectl_defaults before_script: - *prepare_image_name script: - echo "INFO Deploying image $FULL_IMAGE_NAME:$IMAGE_TAG to $CI_ENVIRONMENT_NAME environment..." - export KUBECONFIG=$KUBE_CONFIG - sed -i "s|IMAGE_PLACEHOLDER|$FULL_IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - echo "INFO Applying Kubernetes manifests..." - kubectl apply -f k8s/deployment.yaml - kubectl apply -f k8s/service.yaml - echo "INFO Waiting for deployment rollout to complete..." - kubectl rollout status deployment/cicd-test-project-deployment --timeout=120s needs: - publish-image deploy-dev: stage: deploy-dev extends: .deploy_template environment: name: develop url: https://dev.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "develop"' deploy-release: stage: deploy-release extends: .deploy_template environment: name: release url: https://staging.your-app.com rules: - if: '$CI_COMMIT_BRANCH == "release"' deploy-prod: stage: deploy-prod extends: .deploy_template environment: name: prod url: https://www.your-app.com when: manual allow_failure: false rules: - if: '$CI_COMMIT_BRANCH == "master"'# 主要改动，修改镜像源 FROM 192.168.31.193:20080/docker-proxy/eclipse-temurin:21-jre-alpine # 设置工作目录 WORKDIR /app # 从CI/CD工作区拷贝已经由'build-job'构建好的JAR文件 # 'build-job'的产物(target/目录)在'publish-job'执行时会被自动下载到当前目录 COPY target/*.jar app.jar # 暴露Spring Boot应用程序的默认端口 EXPOSE 8080 # 容器启动时执行的命令 # 使用exec form (json数组格式)，这是容器化应用的最佳实践 ENTRYPOINT ["java", "-jar", "app.jar"]4.3 分层打包分层打包主要是减少镜像的体积，适应云原生的发展。举个例子：我们现在项目cicd-test-project在测试环境已经部署了v1版本，发现有个bug，于是修改一行代码重新部署。但按照之前的打包和Dockerfile，我们其实是将整个项目先打成jar包，再通过java -jar启动，而我们的jar包是fat jar里面包含了三方依赖和我们自己的代码，三方依赖的改动基本很小，而我们自己代码的改动就比较频繁。因此SpringBoot在2.3后通过分层打包，将三方依赖和我们的代码分离，再通过Dockerfile分层构建，就可以复用三层依赖那一层，基本上一个不同版本的镜像只占用我们自己写的代码那一部分，可能仅有不到10MB。以cicid-test-project为例：整个fat jar有50多MB，但我们的代码本身只有4KB。首先修改pom.xml，开启分层打包：<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration> </plugin>修改Dockerfile，分层构建# 一阶段提取 FROM 192.168.31.193:20080/docker-proxy/eclipse-temurin:21-jre-alpine AS extractor WORKDIR workspace ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} application.jar # 运行Spring Boot的layertools来提取所有层到独立的目录中 # 这会创建 common-dependencies, project-dependencies, spring-boot-loader, application 等目录 RUN java -Djarmode=layertools -jar application.jar extract # 二阶段构建 FROM 192.168.31.193:20080/docker-proxy/eclipse-temurin:21-jre-alpine # 设置工作目录 WORKDIR /app # 关键步骤：按稳定性从高到低的顺序，逐层复制文件。 # 每一个COPY指令都会创建一个新的Docker镜像层。 # Docker会缓存这些层，如果层的内容没有变化，构建时会直接使用缓存。 COPY --from=extractor /workspace/dependencies/ ./ COPY --from=extractor /workspace/spring-boot-loader/ ./ COPY --from=extractor /workspace/snapshot-dependencies/ ./ COPY --from=extractor /workspace/application/ ./ # 暴露Spring Boot应用程序的默认端口 EXPOSE 8080 # Spring Boot分层应用需要使用 JarLauncher 来启动 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]这里有一点特殊：笔者目前用的是SpringBoot3.5，分层后启动路径在org.springframework.boot.loader.launch.JarLauncher，如果是SpringBoot2.x，启动路径，是在org.springframework.boot.loader.JarLauncher我们修改自己的代码，将return hello world改为return hello cicd重新部署打包：可以看到前两个镜像共享大小261.5MB，而我们当前最新的镜像仅额外占用8.263KB。分层打包带来的收益是非常高的，一方面可以减少镜像体积，加快镜像的构建，提高镜像分发速率。另一方面分层可以减少我们服务器的磁盘使用。对于长期测试的环境，分层要比不分层磁盘占用至少可以减少90%。这里的服务器包括gitlab CI运行的宿主机、harbor存储空间、运行镜像的k8s节点等。除此以外，如果公司微服务较多，可以在分层打包的基础上提取一些公共依赖层，这些层不仅复用减少磁盘占用，还可以利用COW减少内存的占用。5. CD优化5.1 ingress之前我们将环境分为了开发、预发布和生产环境，有时候这些环境属于不同的k8s集群，我们直接从不同环境拿KUBE_CONFIG设置进gitlab即可。但更多的时候企业仅会使用一或少数几个k8s集群，然后通过namespace来隔离不同环境。但我们知道项目cicd-test-project 的service是nodePort类型，如果多个环境的服务都部署在同一个k8s集群会存在端口冲突问题，这时候就需要引入网关层。常见的网关是ingress和istio，但istio除网关外还包含了服务治理，流量监控，日志追踪等功能，更为复杂，我们这里采用ingress。ingress本质上就是个nginx 7层转发，通过访问不同的url转发到后端不同的service上，而我们后端的service只需要配置为ClusterIp类型即可。wget https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.4/deploy/static/provider/baremetal/deploy.yaml修改deploy.yml：分别在Deployment部分增加replicas:3，多节点部署，保证高可用；在Service部分增加nodePort为80和443，显示指明映射端口默认情况下k8s nodePort只允许是30000-32767，不支持我们上面写的80和443，因此修改三个k8s节点的kube-apiserver.yamlvim /etc/kubernetes/manifests/kube-apiserver.yaml找到spec.containers[0].command，添加一行新的参数：--service-node-port-range=1-65535然后执行kubectl apply -f deploy.yaml通过kubectl get pod -n ingress-nginx kubectl get service -n ingress-nginx查看ingress是否启动正常5.2 修改CI首先修改./k8s/service.yaml，将NodePort类型改为ClusterIpapiVersion: v1 kind: Service metadata: name: cicd-test-project-service spec: type: ClusterIP selector: app: cicd-test-project # 将所有流量转发给带有这个标签的Pod ports: - protocol: TCP port: 80 targetPort: 8080其次修改新增文件./k8s/ingress.yaml：# k8s/ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: # 名称将由CI/CD动态设置 name: cicd-test-project-ingress spec: ingressClassName: nginx rules: - host: "HOST_PLACEHOLDER" # 主机名占位符 http: paths: - path: / pathType: Prefix backend: service: name: cicd-test-project-service port: number: 80然后修改我们的.gitlab-ci.yaml# ============================================================================== # 全局默认设置 (Global Defaults) # ============================================================================== default: tags: - linux - docker variables: IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA .maven_defaults: image: 192.168.31.193:20080/docker-proxy/maven:3.9.6-eclipse-temurin-21 .kubectl_defaults: image: name: 192.168.31.193:20080/docker-proxy/bitnami/kubectl:latest entrypoint: [""] .prepare_image_name: &prepare_image_name - export HARBOR_HOST_WITH_PORT=$(echo $HARBOR_URL | sed -E 's|https?://||; s|/+$||') - export FULL_IMAGE_NAME="$HARBOR_HOST_WITH_PORT/$HARBOR_PROJECT/$CI_PROJECT_NAME" stages: - build - test-and-analyze - publish - deploy-dev - deploy-release - deploy-prod # ============================================================================== # STAGE: build - Compiles, runs unit tests, and packages the application. # ============================================================================== build-and-test: stage: build extends: .maven_defaults script: - echo "INFO [Stage build] Compiling, running unit tests, and packaging..." - mvn -s .mvn/settings.xml verify artifacts: paths: - target/ expire_in: 1 hour rules: - if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: test-and-analyze - Performs SonarQube quality gate check. # ============================================================================== code-quality-scan: stage: test-and-analyze extends: .maven_defaults variables: GIT_DEPTH: "0" script: - echo "INFO [Stage test-and-analyze] Running SonarQube analysis via automatic integration..." - >- mvn -s .mvn/settings.xml verify sonar:sonar -Dsonar.projectKey=test-group_cicd-test-project_AZgXyvpJjZI_rh9cYAvb -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 needs: - job: build-and-test artifacts: true allow_failure: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # ============================================================================== # STAGE: publish - Builds and pushes a Docker image to the registry. # ============================================================================== publish-image: stage: publish image: 192.168.31.193:20080/docker-proxy/docker:latest before_script: - *prepare_image_name script: - echo "INFO [Stage publish] Logging into Harbor $HARBOR_URL" - echo "$HARBOR_PASSWORD" | docker login -u "$HARBOR_USERNAME" --password-stdin $HARBOR_URL - echo "INFO Building Docker image with name $FULL_IMAGE_NAME:$IMAGE_TAG" - docker build -t "$FULL_IMAGE_NAME:$IMAGE_TAG" . - echo "INFO Pushing Docker image $FULL_IMAGE_NAME:$IMAGE_TAG to Harbor" - docker push "$FULL_IMAGE_NAME:$IMAGE_TAG" needs: - job: code-quality-scan artifacts: false - job: build-and-test artifacts: true rules: - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "release" || $CI_COMMIT_BRANCH == "master"' # ============================================================================== # STAGE: deploy-* # ============================================================================== .deploy_template: extends: .kubectl_defaults variables: # 预定义变量，由具体的deploy-job来覆盖 K8S_NAMESPACE: "" APP_HOST: "" before_script: - *prepare_image_name - if [ -z "$K8S_NAMESPACE" ]; then echo "Error=> K8S_NAMESPACE is not set."; exit 1; fi - if [ -z "$APP_HOST" ]; then echo "Error=> APP_HOST is not set."; exit 1; fi script: - echo "INFO Deploying image $FULL_IMAGE_NAME:$IMAGE_TAG to namespace '$K8S_NAMESPACE' with host '$APP_HOST'..." - export KUBECONFIG=$KUBE_CONFIG # --- 动态修改YAML文件 --- - sed -i "s|IMAGE_PLACEHOLDER|$FULL_IMAGE_NAME:$IMAGE_TAG|g" k8s/deployment.yaml - sed -i "s|HOST_PLACEHOLDER|$APP_HOST|g" k8s/ingress.yaml # 执行k8s到具体的namespace - echo "INFO Applying Kubernetes manifests..." - kubectl create namespace $K8S_NAMESPACE || true - kubectl apply -f k8s/deployment.yaml --namespace $K8S_NAMESPACE - kubectl apply -f k8s/service.yaml --namespace $K8S_NAMESPACE - kubectl apply -f k8s/ingress.yaml --namespace $K8S_NAMESPACE # 监控结果 - echo "INFO Waiting for deployment rollout to complete..." - kubectl rollout status deployment/cicd-test-project-deployment --namespace $K8S_NAMESPACE --timeout=120s needs: - publish-image deploy-dev: stage: deploy-dev extends: .deploy_template variables: # 【新增】为开发环境注入特定的命名空间和域名 K8S_NAMESPACE: develop APP_HOST: dev.cicd-test.com environment: name: develop url: http://dev.cicd-test.com rules: - if: '$CI_COMMIT_BRANCH == "develop"' deploy-release: stage: deploy-release extends: .deploy_template variables: # 【新增】为预发布环境注入特定的命名空间和域名 K8S_NAMESPACE: release APP_HOST: release.cicd-test.com environment: name: release url: http://release.cicd-test.com rules: - if: '$CI_COMMIT_BRANCH == "release"' deploy-prod: stage: deploy-prod extends: .deploy_template variables: # 【新增】为生产环境注入特定的命名空间和域名 K8S_NAMESPACE: prod APP_HOST: cicd-test.com environment: name: prod url: http://cicd-test.com when: manual allow_failure: false rules: - if: '$CI_COMMIT_BRANCH == "master"'下面我们需要重新生成一个k8s凭证，由于之前生成凭证是不同环境用了不同的凭证，但这种在一个k8s集群多namespace下其实不用那么麻烦，因此改为生成一个唯一凭证，并为这个凭证授权我们上述namespace的权限。这样无论是哪个环境用的都是一个凭证，方便维护。在k8s任一节点下创建脚本setup-gitlab-k8s-auth.sh#!/bin/bash # ============================================================================== # Kubernetes Auth Setup Script for GitLab CI/CD # # This script provides a one-stop, idempotent solution to configure all # necessary Kubernetes resources for a secure CI/CD integration. # # It performs the following actions: # 1. Creates a centralized Service Account and a permanent token Secret. # 2. Creates target namespaces for deployment (e.g., develop, release, prod). # 3. Applies RBAC Roles and RoleBindings to grant the Service Account # deployment permissions in each target namespace. # 4. Generates a universal Kubeconfig file for use in GitLab CI/CD variables. # # Usage: ./setup-gitlab-k8s-auth.sh # ============================================================================== # --- Script Configuration --- # Exit immediately if a command exits with a non-zero status. set -e # Treat unset variables as an error when substituting. set -u # Pipes fail on the first error. set -o pipefail # --- Core Variables --- # The namespace where the central Service Account and its Secret will reside. readonly SA_NAMESPACE="gitlab-ci" # The name of the Service Account for GitLab Runner. readonly SA_NAME="gitlab-runner-sa" # The name of the Secret that holds the permanent token. readonly SECRET_NAME="gitlab-runner-sa-secret" # A list of target namespaces where applications will be deployed. # You can easily add or remove environments here, e.g., ("develop" "qa" "staging" "prod") readonly TARGET_NAMESPACES=("develop" "release" "prod") # The name for the cluster in the generated Kubeconfig. readonly CLUSTER_NAME="default-cluster" # --- Helper Functions --- # Prints a formatted info message. info() { echo -e "\033[34m[INFO]\033[0m $1" } # Prints a formatted success message. success() { echo -e "\033[32m[SUCCESS]\033[0m $1" } # Prints a formatted error message and exits. error_exit() { echo -e "\033[31m[ERROR]\033[0m $1" >&2 exit 1 } # --- Main Logic --- # Step 1: Create the central Service Account and its permanent token Secret. # This part is idempotent because `kubectl apply` will only create if it doesn't exist, # or update if it does. info "Step 1: Ensuring central Service Account and Secret exist in '${SA_NAMESPACE}' namespace..." cat <<EOF | kubectl apply -f - apiVersion: v1 kind: Namespace metadata: name: ${SA_NAMESPACE} --- apiVersion: v1 kind: ServiceAccount metadata: name: ${SA_NAME} namespace: ${SA_NAMESPACE} --- apiVersion: v1 kind: Secret metadata: name: ${SECRET_NAME} namespace: ${SA_NAMESPACE} annotations: kubernetes.io/service-account.name: ${SA_NAME} type: kubernetes.io/service-account-token EOF success "Central Service Account and Secret are configured." # Step 2: Apply RBAC permissions to all target namespaces. info "Step 2: Applying RBAC Roles and RoleBindings to target namespaces..." for ns in "${TARGET_NAMESPACES[@]}"; do info " - Processing namespace: ${ns}" # The --create-namespace flag makes this idempotent. kubectl create namespace "${ns}" --dry-run=client -o yaml | kubectl apply -f - cat <<EOF | kubectl apply --namespace "${ns}" -f - apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: app-deployer-role rules: - apiGroups: ["", "apps", "networking.k8s.io"] resources: ["deployments", "services", "ingresses", "pods"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: gitlab-ci-rolebinding subjects: - kind: ServiceAccount name: ${SA_NAME} namespace: ${SA_NAMESPACE} roleRef: kind: Role name: app-deployer-role apiGroup: rbac.authorization.k8s.io EOF done success "RBAC permissions applied to all target namespaces." # Step 3: Generate the final Kubeconfig. info "Step 3: Generating the universal Kubeconfig..." # Fetch cluster connection details. readonly SERVER_URL=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') readonly CA_DATA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') # Fetch the permanent token from the Secret. readonly TOKEN=$(kubectl get secret "${SECRET_NAME}" --namespace "${SA_NAMESPACE}" -o jsonpath='{.data.token}' | base64 --decode) if [ -z "$TOKEN" ]; then error_exit "Token could not be retrieved. Please check the Secret and RBAC permissions." fi success "Kubeconfig generated successfully." # --- Final Output --- # Provide clear instructions to the user. echo echo -e "\033[32m========================= ACTION REQUIRED =========================\033[0m" echo "Copy the entire YAML content below and paste it into a GitLab CI/CD variable." echo echo " - GitLab Project: Your project" echo " - Variable Key: \033[33mKUBE_CONFIG\033[0m" echo " - Variable Type: \033[33mFile\033[0m" echo " - Environment: \033[33mAll (*)\033[0m" echo " - Flags: \033[33m[x] Protect variable, [ ] Mask variable\033[0m" echo -e "\033[32m===================================================================\033[0m" echo # Use a Here Document for clean output. cat <<EOF apiVersion: v1 kind: Config clusters: - name: ${CLUSTER_NAME} cluster: server: ${SERVER_URL} certificate-authority-data: ${CA_DATA} contexts: - name: ${CLUSTER_NAME} context: cluster: ${CLUSTER_NAME} user: ${SA_NAME} current-context: ${CLUSTER_NAME} users: - name: ${SA_NAME} user: token: ${TOKEN} EOFchmod +x setup-gitlab-k8s-auth.sh ./setup-gitlab-k8s-auth.sh上述脚本核心内容是：创建一个名为gitlab-ci的namespace生成一个永久的Token和Secret账户为上述账户授权我们对develop、release和prod三个namespace的资源权限提取出账户的Token我们将执行结果贴近gitlab ci的variables，现在所有环境只需要一份KUBE_CONFIG了本地新增host，将不同环境域名都指向k8s vip192.168.31.100 cicd-test.com dev.cicd-test.com release.cicd-test.com部署完服务后，我们就可以通过上述域名访问不同环境的服务了。5.3 环境变量现实开发中项目的运行往往依赖很多配置，比如常见的数据库、Redis配置等。以SpringBoot为例，一般自测本地运行的时候我们的配置文件可能是这样：spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.31.166:3306/cicd-test?useUnicode=true&useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf8&rewriteBatchedStatements=true&serverTimezone=GMT%2B8 username: root password: 123456但不同环境数据库的连接不同，常见的开发方式是将这些配置信息放入环境变量，我们从环境变量读取这些配置，这时我们的配置文件就改为：spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${JDBC_URL} username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD}可以看到我们将从环境变量加载这些配置。现场运维人员负责环境变量的注入，而环境变量注入我们往往采取k8s的configmap或secret，因此运维人员往往需要维护这样一份文件：# configmap-prod.yaml apiVersion: v1 kind: ConfigMap metadata: name: cicd-test-project-config namespace: prod data: JDBC_URL: "jdbc:mysql://prod-mysql-service:3306/seed-sync?useUnicode=true&useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf8&rewriteBatchedStatements=true&serverTimezone=GMT%2B8" DATABASE_USERNAME: "root" --- # secret-prod.yaml apiVersion: v1 kind: Secret metadata: name: cicd-test-project-secret namespace: prod type: Opaque data: # Base64 DATABASE_PASSWORD: "MTIzNDU2"运维人员在环境一开始执行这份文件，让环境的配置里包含我们需要的信息。我们还需要修改下k8s的deployment，保证从configmap或secret中加载这些环境变量：apiVersion: apps/v1 kind: Deployment metadata: name: cicd-test-project-deployment labels: app: cicd-test-project spec: replicas: 3 selector: matchLabels: app: cicd-test-project template: metadata: labels: app: cicd-test-project spec: containers: - name: cicd-test-project-container image: IMAGE_PLACEHOLDER ports: - containerPort: 8080 # 从configmap和secret中读取环境变量 envFrom: - configMapRef: name: cicd-test-project-config - secretRef: name: cicd-test-project-secret因此实际企业开发过程往往是这样的：开发人员将所有需要的配置均从环境变量引入；运维人员则负责维护一套环境变量信息。而这个信息就是上述ConfigMap和Secret的那份yaml，同时这份yaml也是被Git管理的。环境部署的时候要先执行配置文件，再执行应用的部署。6. 总结我们以一个简单的SpringBoot项目为例，从简到繁，慢慢优化，做到了一套企业级的CICD流程。整个流程如下：项目采用Gitflow为基本的分支管理策略，feature分支push的时候触发打包和单元测试、feature MR到develop的时候触发打包单元测试和代码扫描，校验通过才允许MR，MR到develop分支后，触发develop分支的打包、单元测试、代码扫描和镜像构建与推送以及自动部署到开发环境，其他分支和环境同理集成SonarQube做代码的门禁检测使用nexus做私有maven仓库，加快CI的构建使用harbor做私有镜像仓库，缓存三方镜像与自己服务的镜像（同时还可以做镜像扫描）使用分层打包，适应云原生，减少镜像体积在一套k8s上通过namespace隔离不同环境使用ingress，通过不同路由来访问不同环境服务通过k8s的ConfigMap和Secret来作为环境变量，服务使用的外部配置直接从环境变量引入附录1：GitOps上述CICD无论是CI流水线、k8s deployment、service和ingress还是环境变量(k8s ConfigMap和Secret)我们均采用代码的形式，而非在一些可视化页面勾勾点点，通过UI实现的方式。且这些信息与代码一样均是被Git管理的，这其实就是现今主流的一种Devops最佳实践：GitOps。相比于上面说的在可视化页面上来勾勾点点实现CI的方式，GitOps至少有如下优点：来源唯一可追溯：无论是代码、代码的CI流水线、代码的部署方式还是代码依赖的环境变量均从单一的Citlab出，且Git记录了每一次变更、变更人、变更时间、以及通过MR进行的变更评审，这种单一来源可以避免不同环境部署错乱问题，Git记录方便出了问题能排查出问题的时间、人员和原因等。自动操作，杜绝手动人为失误： 流程不再依赖人的记忆和手动操作。代码化的配置消除了“点错按钮”、“填错数字”这类低级但后果严重的失误。灾难恢复容易：假设我们的CI工具，如jenkins或gitlab、gitea等发生故障，只能重建，那页面操作那些行为就得重新手动再操作一遍，还得和故障发生前的手动配置完全一模一样。但现在我们所有配置都写在了代码里，不依赖于任何平台，即使CI数据全部丢失完全重建，也对我们没任何影响。一键重建，轻松复制：对于不同的环境或不同的项目，我们仅需要修改文件中的几个小配置即可完整对新项目的复制和新环境的重建。当然更成熟的GitOps还需要配合一些GitOps工具，如Argo CD，但这对运维人员的要求也会更高，我们上面这些方案基本已经完全够用了。附录2： k8s集群搭建k8s集群部署可以分为master-worker节点分开或ha-master两种架构，为合理利用资源我们这里选择ha-master，三台服务器均为k8s master，通过配置一个vip来达到高可用。2.1 安装k8s配置主机名和hosts# 在 192.168.31.240 上 sudo hostnamectl set-hostname k8s-master-1 # 在 192.168.31.66 上 sudo hostnamectl set-hostname k8s-master-2 # 在 192.168.31.97 上 sudo hostnamectl set-hostname k8s-master-3编辑hosts文件，添加如下内容192.168.31.240 k8s-master-1 192.168.31.66 k8s-master-2 192.168.31.97 k8s-master-3禁用swapsudo swapoff -a sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstabroot@k8s-master-1:/etc# systemctl --type swap --all UNIT LOAD ACTIVE SUB DESCRIPTION dev-vda3.swap loaded inactive dead Swap Partition LOAD = Reflects whether the unit definition was properly loaded. ACTIVE = The high-level unit activation state, i.e. generalization of SUB. SUB = The low-level unit activation state, values depend on unit type. 1 loaded units listed. To show all installed unit files use 'systemctl list-unit-files'. root@k8s-master-1:/etc# root@k8s-master-1:/etc# systemctl mask dev-vda3.swap Created symlink /etc/systemd/system/dev-vda3.swap → /dev/null.验证：free -h 命令输出中Swap应为0。启用iptables对桥接流量的处理cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-ip6tables = 1 net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system验证：lsmod | grep br_netfilter 和 sysctl net.ipv4.ip_forward (应为1) 等。安装k8s组件sudo apt-get install -y apt-transport-https ca-certificates curl gpg sudo mkdir -p /etc/apt/keyrings curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt-get update sudo apt-get install -y kubelet kubeadm kubectl sudo apt-mark hold kubelet kubeadm kubectl配置容器运行时sudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml sudo systemctl restart containerd拉取镜像# 需要containerd 配置代理 sudo kubeadm config images pull配置VIP目前多节点vip通过keepalived管理安装keepalivedapt-get update apt-get install -y keepalived创建检测脚本cat > /etc/keepalived/check_apiserver.sh << EOF #!/bin/sh # 检查本地API服务器 errorExit() { echo "*** $*" 1>&2 exit 1 } curl --silent --max-time 2 --insecure https://localhost:6443/healthz -o /dev/null || errorExit "Error: API server unreachable" # 如果连接到API服务器没有问题，则退出时返回状态码0 exit 0 EOF chmod +x /etc/keepalived/check_apiserver.sh配置keepalived.conf，三个节点优先级分别为100、90和80k8s-master-1cat > /etc/keepalived/keepalived.conf << EOF global_defs { router_id LVS_KUBERNETES script_user root enable_script_security } vrrp_script check_apiserver { script "/etc/keepalived/check_apiserver.sh" interval 3 weight -2 fall 3 rise 2 } vrrp_instance VI_1 { state MASTER interface enp1s0 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass kubernetes } virtual_ipaddress { 192.168.31.100/24 } track_script { check_apiserver } } EOFk8s-master-2cat > /etc/keepalived/keepalived.conf << EOF global_defs { router_id LVS_KUBERNETES script_user root enable_script_security } vrrp_script check_apiserver { script "/etc/keepalived/check_apiserver.sh" interval 3 weight -2 fall 3 rise 2 } vrrp_instance VI_1 { state BACKUP interface enp1s0 virtual_router_id 51 priority 90 nopreempt advert_int 1 authentication { auth_type PASS auth_pass kubernetes } virtual_ipaddress { 192.168.31.100/24 } track_script { check_apiserver } } EOFk8s-master-3cat > /etc/keepalived/keepalived.conf << EOF global_defs { router_id LVS_KUBERNETES script_user root enable_script_security } vrrp_script check_apiserver { script "/etc/keepalived/check_apiserver.sh" interval 3 weight -2 fall 3 rise 2 } vrrp_instance VI_1 { state BACKUP interface enp1s0 virtual_router_id 51 priority 90 nopreempt advert_int 1 authentication { auth_type PASS auth_pass kubernetes } virtual_ipaddress { 192.168.31.100/24 } track_script { check_apiserver } } EOF启动keepalivedsystemctl enable keepalived systemctl start keepalived systemctl status keepalived初始化第一个控制平面节点(192.168.31.240)创建kubeadm-config.yaml文件# 第一部分：InitConfiguration - 定义此节点在'init'过程中的特定行为 apiVersion: kubeadm.k8s.io/v1beta3 kind: InitConfiguration # localAPIEndpoint 定义了本节点API Server广播给其他集群成员的地址 localAPIEndpoint: # advertiseAddress 必须是本节点的物理IP地址 advertiseAddress: 192.168.31.240 bindPort: 6443 # nodeRegistration 定义了本节点注册到集群时的额外信息 nodeRegistration: kubeletExtraArgs: # 确保kubelet的cgroup驱动与容器运行时(containerd)的cgroup驱动一致 cgroup-driver: "systemd" --- # YAML文档分隔符，用于分隔InitConfiguration和ClusterConfiguration # 第二部分：ClusterConfiguration - 定义整个集群范围的共享配置 apiVersion: kubeadm.k8s.io/v1beta3 kind: ClusterConfiguration # 指定您安装的Kubernetes版本 kubernetesVersion: v1.29.0 # controlPlaneEndpoint 是集群控制平面的统一入口，必须设置为VIP地址 controlPlaneEndpoint: "192.168.31.100:6443" # networking 定义了集群的网络参数 networking: # podSubnet 是分配给Pod的IP地址范围，需与CNI插件配置保持一致 # Calico默认使用192.168.0.0/16，但10.244.0.0/16是另一个常用选择 podSubnet: "10.244.0.0/16" # etcd 的配置 etcd: local: # serverCertSANs 和 peerCertSANs 是为etcd的证书添加的备用名称 # 必须包含所有控制平面节点的IP和主机名，以确保etcd成员间通信的TLS验证成功 serverCertSANs: - k8s-master-1 - k8s-master-2 - k8s-master-3 - 192.168.31.240 - 192.168.31.66 - 192.168.31.97 peerCertSANs: - k8s-master-1 - k8s-master-2 - k8s-master-3 - 192.168.31.240 - 192.168.31.66 - 192.168.31.97 # apiServer 的配置 apiServer: # certSANs 是为API Server的证书添加的备用名称（Subject Alternative Names） # 这是一个极其重要的部分，必须包含所有可能用于访问API Server的地址 certSANs: # 1. 必须包含VIP地址 - "192.168.31.100" # 2. 必须包含所有控制平面节点的物理IP地址 - "192.168.31.240" - "192.168.31.66" - "192.168.31.97" # 3. 必须包含所有控制平面节点的主机名 - "k8s-master-1" - "k8s-master-2" - "k8s-master-3" # 4. 包含标准的Kubernetes服务名称和IP - "kubernetes" - "kubernetes.default" - "kubernetes.default.svc" - "kubernetes.default.svc.cluster.local" kubeadm init --config kubeadm-config.yaml --upload-certs这里会打印出一条join指令，用于其他控制平面的加入，我们需要记下来，下面会用到配置kubectl和安装网络插件上一步控制平面安装完成后会打印出日志让我们配置kubectl和安装网络插件配置kubectlmkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config验证kubectl：kubectl get nodes输出结果将看到k8s-master-1，状态为NotReady安装Calico网络插件kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml# 每隔几秒运行一次，直到状态变为Ready watch kubectl get nodes当k8s-master-1的STATUS从NotReady变为Ready时，按Ctrl+C退出。这通常需要1-3分钟。将其他节点加入到控制平面将管理VIP的脚本同样拷贝到其他节点，并创建systemd服务来管理# 在 k8s-master-1 上执行: scp /usr/local/bin/vip_manager.py root@k8s-master-2:/usr/local/bin/ scp /usr/local/bin/vip_manager.py root@k8s-master-3:/usr/local/bin/将节点加入集群（第8步中记录的指令）kubeadm join 192.168.31.100:6443 --token <your_token> \ --discovery-token-ca-cert-hash sha256:<your_hash> \ --control-plane --certificate-key <your_cert_key>mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config验证安装成功：root@k8s-master-1:~# kubectl get node NAME STATUS ROLES AGE VERSION k8s-master-1 Ready control-plane 13h v1.29.15 k8s-master-2 Ready control-plane 6m43s v1.29.15 k8s-master-3 Ready control-plane 4m15s v1.29.15移除NoSchedule污点为让Master节点也可以部署普通pod，可以将master节点上的NoSchedule污点移除，也可以让pod容忍NoSchedule污点，我们为简单，直接采取前者方案，移除三个master节点上的NoSchedule污点root@k8s-master-1:~# kubectl describe node k8s-master-1 | grep Taints Taints: <none> root@k8s-master-1:~# kubectl describe node k8s-master-2 | grep Taints Taints: <none> root@k8s-master-1:~# kubectl describe node k8s-master-3 | grep Taints Taints: <none>2.2 验证k8snginx-test.yaml# --- # Kubernetes Deployment for NGINX # This object manages the lifecycle of the NGINX Pods. # --- apiVersion: apps/v1 kind: Deployment metadata: # The name of the Deployment. This will be used as a prefix for Pod names. name: nginx-deployment # Labels applied to the Deployment object itself. Useful for organization. labels: app: nginx spec: # The desired number of Pod replicas. Setting to 5 increases the likelihood # of Pods being scheduled across multiple nodes, including control planes. replicas: 5 # The selector determines which Pods this Deployment manages. # It must match the template's labels. selector: matchLabels: app: nginx # The template for the Pods that will be created. template: metadata: # Labels applied to each Pod created by this Deployment. # The Service will use these labels to find the Pods. labels: app: nginx spec: containers: - name: nginx-server # The container image to use. 'nginx:latest' is a standard, lightweight web server. image: nginx:latest # The port that the NGINX container will listen on. ports: - containerPort: 80 protocol: TCP # This toleration allows the Pods to be scheduled on control-plane nodes # even if the NoSchedule taint is present. It's a robust practice. tolerations: - key: "node-role.kubernetes.io/control-plane" operator: "Exists" effect: "NoSchedule" --- # --- # Kubernetes Service of type NodePort for NGINX # This object exposes the NGINX Deployment to traffic from outside the cluster. # --- apiVersion: v1 kind: Service metadata: # The name of the Service. name: nginx-service spec: # Type: NodePort makes the service accessible on a static port on each node's IP. type: NodePort # This selector must match the labels of the Pods to which traffic should be forwarded. selector: app: nginx # This section defines the port mapping for the service. ports: # A single port definition for this service. - name: http # Naming the port is a good practice. protocol: TCP # The port on the Service's own ClusterIP. port: 80 # The port on the backend Pods (the containerPort). targetPort: 80 # The static port opened on each cluster node. nodePort: 30080 如上创建了5个副本的nginxkubectl apply -f nginx-test.yaml root@k8s-master-1:/home/k8s# kubectl get pod -o wide | grep nginx nginx-deployment-7668495448-6tk4l 1/1 Running 0 3m16s 10.244.182.67 k8s-master-2 <none> <none> nginx-deployment-7668495448-gxgh9 1/1 Running 0 3m16s 10.244.168.3 k8s-master-3 <none> <none> nginx-deployment-7668495448-hv2gk 1/1 Running 0 3m16s 10.244.168.4 k8s-master-3 <none> <none> nginx-deployment-7668495448-qkd44 1/1 Running 0 3m16s 10.244.182.68 k8s-master-2 <none> <none> nginx-deployment-7668495448-zsl6d 1/1 Running 0 3m16s 10.244.196.5 k8s-master-1 <none> <none> 通过http://192.168.31.100:30080/访问，可以看到nginx首页。
操作系统导论笔记——虚拟化本文是笔者在阅读操作系统导论 时做的笔记。虽然这本书笔者曾反复阅读过多次，但每次阅读都有不少收获（主要是读后就忘）。笔者最近决定重读本书，并做一份笔记。本文的笔记不是对原书的摘抄或总结，而是笔者自身的理解和感悟，本文中部分疑惑都是从AI那里解答的，但整篇笔记依然是笔者用自己的写的，杜绝使用任何AI直接生成的东西。操作系统导论 共分三个部分：虚拟化、并发和持久性，本篇笔记是第一部分：虚拟化1. CPU虚拟化操作系统实现cpu虚拟化主要依赖两个机制：对需要访问硬件的操作做限制，要求只能在内核态执行对硬件的访问，如读写文件，读写网卡等。这种受限访问就是我们说的系统调用，一般通过软中断实现。时分共享。每个进程仅能持续占用cpu一小段时间，不同的进程交替获得cpu执行，就好像每个进程都拥有cpu一样。时分共享实现的核心是时钟中断，硬件设备时钟会每隔一定时间给cpu发送硬中断，检测到硬中断的cpu会跳转到操作系统预置的中断处理程序中，此时操作系统重新获得cpu控制权，然后决定要继续运行当前进程，还是切换一个新的进程来运行。系统调用保证了进程对硬件访问的保护以及提供了统一的抽象，而时分共享则保证了操作系统可以频繁的拿到cpu的控制权，并决定进程的调度，避免一些用户进程长时间占用cpu问题。1.1 上下文切换当前CPU运行进程A，此时发生硬中断，cpu会自动将进程A的关键寄存器，如PC、SP、Flag等信息压入进程A的内核栈上，cpu根据中断向量号找到中断向量表中对应的ISR程序入口，同时切换到内核模式。内核会将当前进程A的所有通用寄存器的信息（如EAX，EBX等）继续保存到内核栈，这样就相当于内核栈当前栈顶保存了进程A用户态下的所有寄存器信息，记录了进程A在用户态执行的完整快照，这段将用户态进程A的上下文保存到内核栈栈顶的结构叫trapframe。然后内核开始执行ISR代码，如果ISR代码判断无需进行上下文切换，则将之前保存在trapframe中的通用寄存器(EAX, EBX 等)从A的内核栈中弹出并恢复到CPU寄存器。然后执行iret，cpu识别到iret返回后，自动从当前sp指向的栈（此时是A的内核栈）的栈顶pop出User PC, User SP, Flags等（也即进程A在用户态的关键上下文）并加载到自己的寄存器中，此时切换到用户态，cpu开始继续执行进程A的代码。如果判断需要进行上下文切换则执行swicth()程序，switch程序做两件事：当前正处在进程A的内核态下，cpu寄存器信息是进程A内核态执行的上下文，因此我们需要先将这些关键信息，如PC、SP等保存起来，使用context保存，context位于PCB或TCB等内存中。将要执行的进程B的context加载进CPU的寄存器，可以很显然的知道，进程B的context其实就是进程B在内核态执行的上下文，此时CPU的SP寄存器就是进程B的内核栈，而PC指向的也是上一次进程B在内核态执行中止的下一条指令地址。此时CPU再取指执行，其实就是恢复之前进程B的内核态执行代码。因此switch函数返回后，其实整个执行已经被换为了进程B的内核态，进程B可能会在内核态再做一些收尾工作，将自己内核栈的trapframe中的通用寄存器(EAX, EBX 等)恢复到cpu，然后执行iret准备将控制权转移到用户态，也即进程B的用户态，CPU判断到iret指令后，就从SP指向的内核栈pop出栈顶的User PC, User SP, Flags等，因为当前sp指向的是进程B的内核栈，因此此时的trapFrame自然是用户态进程B的完整上下文。上述过程便完成了一次完整的进程上下文切换。我们可以将整个流程梳理为：用户态进程A => 内核态进程A => 内核态进程B => 用户态进程B。其中用户态进程A的完整上下文保存在进程A的内核栈叫trapFrame，而内核态进程A的上下文保存在TCB或PCB中叫context。如果以后某个时刻进程A被恢复运行，那恢复的点其实就是内核态进程A switch()函数切换完成的点，此时内核态进程A恢复运行，cpu的寄存器已经换为了内核态进程A的上下文（也即A的context），然后内核态进程A执行iret，cpu捕捉到从A的内核栈pop出trapFrame，正是我们之前保存的用户态进程A的上下文，此时用户态进程A就得以恢复运行。上述流程也可以理解为：一旦进程由用户态切为内核态，操作系统都需要完整备份整个用户态的上下文信息，只不过这个上下文信息是通过两阶段实现的，首先是cpu先将用户态的关键寄存器如PC、SP、Flag等保存进内核栈，然后切换到内核态，内核程序将再用户态的剩余完整寄存器保存到内核态。同样的道理，上下文的恢复也是两阶段恢复，内核态先将其他寄存器信息恢复，再执行iret由cpu恢复PC、SP等关键寄存器。两阶段执行的思想有些像linux的ksoftirqd，CPU执行最紧要的部分，然后交由内核分步执行剩余的部分。在上下文保存的基础上，ISR如果判断需要进行上下文切换，则会备份当前内核态的上下文，然后切到别的内核进程恢复执行（将cpu寄存器恢复为别的进程的内核态上下文），这是因为我们当前要暂停的进程是在内核态暂停的，虽然我们之前备份了用户态的上下文，但还需要再备份下内核态的上下文，这样如果后续恢复运行，可以从内核态暂停的地方继续运行。或者我们再精简些，将上述CPU与内核的两阶段上下文保存视为一体，那就是先进行用户态上下文的保存，如果需要进行上下文切换，那就再保存当前内核态的上下文，并加载要切换的进程的内核态上下文，随后已经切换的内核态进程再执行iret加载切换后的用户态上下文切到用户态执行切换后的用户进程，这也就是原书中的运行进程的用户寄存器由硬件隐式保存，使用该进程的内核栈。当操作系统决定从 A 切换到 B。在这种情况下，内核寄存器被软件（即 OS）明确地保存， 但这次被存储在该进程的进程结构的内存中。后一个操作让系统从好像刚刚由 A 陷入内核， 变成好像刚刚由 B 陷入内核2. 内存虚拟化内存虚拟化主要解决了两个问题：隔离和保护，避免进程可以读到甚至篡改另外一些进程内存的情况抽象，让程序和编写程序的人觉得自己是在享有整个内存，使用的是一整个连续的地址空间，无需考虑不同物理内存碎片下放不同大小的数据问题。而操作系统为进程提供的内存抽象就叫地址空间。地址空间的结构大致如下图：代码放在一开始的位置，大小可以提前确定，随后是堆的位置，堆从上往下增长（从低地址往高地址增长），而栈的起始地址在最后，从下往上增长（从高地址往低地址增长），堆和栈之间的是空闲的还未使用，可以被申请的地址。比如你的物理内存是4GB，那地址空间就可以看作是一个4GB大小的连续数组，程序会认为自己可以完整的利用这4GB内存，其中假设代码占用1KB，堆栈100MB，栈占100MB，那就是地址空间的地址[0,1kB)存储的是代码，[1KB,1kB+100MB)的地方存储的是堆，(4GB,4GB-100MB]存储的是栈。而[100MB+1KB,4GB-100MB)之间这段地址是还未被使用的地址，堆可以使用，栈也可以使用。由于每个进程都有自己的地址空间，而地址空间的大小往往与物理内存的大小相等，因此每个进程都会认为自己享有整个物理内存。但实际我们知道物理内存只有一个，所有进程的数据都存在同一个物理内存中，因此我们就需要一套地址空间与实际物理内存地址的转换机制。下面我们来讨论下现代CPU的内存管理单元（MMU）如何高效的将地址空间的虚拟地址转为物理地址的。2.1 基址+界限最初始的转换机制是假设地址空间占用没物理内存那么大，整个地址空间必须连续的放入物理内存中，且每个地址空间的大小完全一样。如上图所示，一个16KB的地址空间被映射到一个64KB的物理内存中。这时候其实我们只需要两个信息：一个是基址，一个是界限。其中基址代表的是地址空间在物理空间存储的起始偏移量，在上图中是32KB，而界面用于限制程序对地址的访问不超过自己的地址空间，在上图中是16KB。因此物理地址 = 基址 + 地址空间的虚拟地址。比如访问地址空间地址128的代码段，实际读取的是32KB+128（32896） 物理地址位置的数据。也因此每个CPU都有两个硬件寄存器：基址寄存器和界限寄存器由于上述假设足够的简单，因此操作系统对内存的管理也足够简单，身为操作系统必须清楚物理空间的哪块地址被占用哪块是空闲的，我们假设操作系统维护了一个空闲列表，而由于上例我们假设进程地址空间相同，因此物理空间就可以想象为组槽，槽的大小就是地址空间的大小，空闲列表里记录的也是槽的位置。当一个新的进程被创建就从空闲列表中找一个新槽，将该槽变为占用，并记录其基址和界面，同样进程终止也需要回收槽，将可用槽放回到空闲列表。我们需要明确的是，CPU只有一组基址寄存器，其基址寄存器和界限都是针对当前CPU正在运行的进程的，每个进程的基址寄存器不同，因此在上下文切换的时候我们需要保存当前进程的基址寄存器和界面寄存器（一般放入进程结构或进程控制块中），然后再加载要切换的进程的基址界面到相应的寄存器中。2.2 分段上述地址空间映射存在一个比较明显的问题：由于大部分进程无法用满整个地址空间，存在大量的空闲区域，如果直接整体映射，会导致物理内存中也占用了大量未使用的内存。如上图所示，地址空间4KB-14KB是未使用的空间，但其直接映射到物理空间的36KB-46KB导致物理内存中消耗了这段物理内存地址。解决这种问题的方案是采取分段。也即在 MMU 中引入不止 一个基址和界限寄存器对，而是给地址空间内的每个逻辑段（segment）一对。一个段只是 地址空间里的一个连续定长的区域，在典型的地址空间里有 3 个逻辑不同的段：代码、栈 和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域，从而避免了虚 拟地址空间中的未使用部分占用物理内存比如按上图中我们分别为代码段、栈和堆分别单独记录他们的基址和界限，如下图所示：这样每一个段属于连续的地址空间，可以独立映射到物理内存，而无需将整个地址空间整体映射。按上图，比如要引用在代码段中的虚拟地址 100，MMU 将基址值加上偏移量（100）得到实际的物理地址：100 + 32KB = 32868。然后它会检查该地址是否在界限内（100 小于 2KB），发现是的，于是发起对物理地 址 32868 的引用。再比如堆中的地址，虚拟地址 4200，因为堆从虚拟地址 4K（4096）开始，4200 的 偏移量实际上是 4200 减去 4096，即 104，然后用这个偏移量（104）加上基址寄存器中的 物理地址（34KB），得到真正的物理地址 34920。因此对于分段的物理地址转换规则是：先判该虚拟地址在哪个段，然后得到当前虚拟地址在段中的偏移量，最后再加上该段的基址。判断虚拟地址在哪个段可以用虚拟地址的头标识，比如我们有代码段、堆和栈三个段，那两位头标识就足以表示（00代码、10堆、11段）。假设对于一个14位的虚拟地址，我们采用头两位表示段标识，后12位表示段内偏移量。2.3 分页分段虽然解决了未使用地址空间占用物理内存问题，但带来了内存空间碎片化。因为每个段都是一个独立的连续地址，直接映射到物理空间，段的大小很可能不统一，各种各样的大小都有，物理内存会很快充满许多空闲空间的小洞，从而很难再分配给新的段，或扩大已有的段。针对这个问题，解决方案是分页，所谓页可以理解为系统进行虚拟内存管理和分配物理内存的基本单位，也是虚拟地址空间和物理地址空间之间映射的基本单位我们将一个进程的地址空间分割成固定大小的单元，每个单元称为一页，相应地，把物理内存看成是定长槽块的阵列，每个槽块的大小与页的大小一致，叫作页帧。因此每一个进程的地址空间被使用的页都会被映射到物理内存的页帧上，对于虚拟内存到物理内存的映射也就变为了地址空间页号到物理页帧的映射。而其中原本连续的虚拟地址空间就可以拆分为页，映射到不连续的物理页帧。如上图，地址空间的第0页被映射到了物理空间的页帧3、地址空间第1页映射到了物理页帧7、地址空间第2页映射到了物理页帧5、地址空间第3页映射到了物理页帧2。可以看到分页后，操作系统无需关心进程是如何使用地址空间，也无需关心堆和栈的增长方向的。由于最小单元是页，因此也避免了物理空间外部碎片化问题。为了将一个进程虚拟的页号映射物理页帧，操作系统通常为每个进程保 存一个数据结构，称为页表（page table）。页表的主要作用是为地址空间的每个虚拟页面保 存地址转换（address translation），从而让我们知道每个页在物理内存中的位置。需要记住的是每个进程都有一个页表。以一个64字节的虚拟地址空间为例，它的虚拟地址需要6位（2^6=64），我们假设页大小是16字节，那就可以用高2位表示页号，低4位表示页内偏移量，如下图，其中VPN代表虚拟页号。因此假设我们访问地址空间21的地址，转为2进制如下：虚拟页号就是1，页内偏移量是5。这时候我们只需要查找当前进程的页表，找到虚拟页1对应的物理页帧（PFN），假设是111，将虚拟页替换为物理页帧再加上页内偏移量就是实际的物理地址。一个x86架构的页表条目大概如下：它包含一个存在位（P），确定是否允 许写入该页面的读/写位（R/W） 确定用户模式进程是否可以访问该页面的用户/超级用户位 （U/S），有几位（PWT、PCD、PAT 和 G）确定硬件缓存如何为这些页面工作，一个访问位 （A）和一个脏位（D），最后是页帧号（PFN）本身。可以看到上述一个页表条目占用4字节。对页表信息的存储我们可以采取简单线性表，也即数组，数组下标为虚拟页号，数组元素为页表条目，这样我们就能立即通过虚拟页号找到页表条目，也就能确定物理页帧了。分页存在两个比较明显的问题：我们其实是对整个地址空间进行了统一分页，而我们知道每一页都需要一个页表条目来映射，假设对于32位操作系统下4GB的地址空间，页大小为4KB，那就代表一个地址空间有2^20^ 个页，也就是有2^20^ 个页表条目，4字节 * 2^20^ = 400MB，也即一个4GB地址空间的进程光维护地址转化的页表就需要400MB的空间，更别提现在64位操作系统了。由于页表条目那么大，它也没法装入cache或寄存器中，这就导致这种情况下的页表只能存入内存，而假设我们需要访问一段数据，则需要先将这段数据的虚拟地址转为物理地址，而转换需要页表，页表在内存中，这就需要一次内存访问，拿到物理地址后再去内存中取实际值，这又是一次内存访问，因此整个过程还是比较慢的。针对第二种情况，解决方案是引入TLB，而TLB其实就是一个硬件，干的是缓存的是，缓存当前VPN对应的PFN是什么。我们知道所有缓存其实核心都是利用的是空间局部性和时间局部性。TLB为了速度因此做的比较小，一般TLB可能只有32条或64条，如果有未命中的TLB查询，则还是需要去内存的页表中查询，查询后将查询结果记录进TLB，如果TLB满了则需要替换策略，如LRU。另外，我们知道现代操作系统有大页缓存的功能，大页缓存其实就是把一页的大小由常见的4KB改为2MB、1GB等，大页缓存虽然可以减少页表条目，但其核心目的还是提供TLB的命中率。比如在一些数据库场景，数据存储的是一块比较大的连续区域，如果采用原来的页大小，那会存在较多的页表条目，这样也会造成TLB中条目不够，存在未命中的情况。但通过将每页变大，减少页表的条目，使得TLB所能表示的内存范围更大，则更容易命中，从而避免TLB未命名导致的低性能问题。针对第一个问题，采取的可以是分段+分页，类似于之前的分段，每个段都单独有一个自己的页表，这样地址空间中间未使用的空洞部分就可以不被记录。还可以是多级页表，多级页表的思路比较简单，就是对页表加索引，可以往上加多层索引，类似于跳表或B树。对于一整页的页表记录都是无效的，则直接不申请这一页。多级页表有一个核心思想是页表或页表的索引（目录）也是存储在页帧里。举例如下：对于上述14位地址空间，页大小是64字节，一个页表条目占4字节。那页表条目就是2^8^个，那么一页可以装入64/4 = 16个页条目。因此按之前的分页，页条目就需要占2^8^/16 = 16页。但多级页表的好处是，对于一整页的页表记录都是无效的，则直接不分配这一页。我们知道地址空间中间有大量未使用的空洞部分，而这些就可以不分配页记录。但我们其实又需要知道，这些页表所在的页哪些是没分配的，哪些是已分配的，因此这时候就需要在原页表上建索引，索引指向的是分配页表时所在的页。比如上例的页表如下：VPNPFNvalid010112312--03--048015591...........126551127451上述2^8^页表条目，中间大部分是空缺的，我们就可以省略，由于一页可以装入16个页表条目，则页表条目在实际分配的如下：VPN 0-16页表条目分配在物理页100中，中间这些没分配，VPN 110-127分配在物理页101中。那其实上述信息就是建立了一个页表的目录，我们叫做页目录：页表的页PFNvalid010011--02--0.........151011其中页目录的条目数是页表理论可分配的页数量，上述为16条，页目录正好占1页。这样对于上述地址空间，我们就可以由1页页目录，2页页表来完成，不再是原来的16页页表。我们以虚拟地址11 1111 1000 0000为了来说下多级页表如何转换为物理地址：当我们寻址的时候，寄存器内会存储页目录地址，找到页目录。然后根据VPN高4位代表的是页目录内的偏移量找到对应的页表的页，高4位是1111，对应第15项，找到其页表在物理的101页。因此访问物理的101页，根据中间4位代表其在页表内的偏移量找到对应的页表条目，1110，对应倒数第二项，也即VPNPFNvalid126551因此实际PFN是55，再加上低6位页内偏移量000000，因此最终物理地址就是0011 0111 000000上面我们只是两级页表，但很显然，可以构造更高，多级页表：如上就是三级页表。2.4 小知识补充以前我一直以为32位操作系统的物理内存最大是4GB，但后来了解到是可以突破4GB的，如向下图的一个TLB条目：VPN19位，页内偏移量12位，用户可用的总地址空间是2GB（还一半给了内核），但VPN转为PFN的时候，PFN是24位，那PFN+页内偏移地址 = 36位，可表示的物理地址空间就是64GB。也就是说即使是32位操作系统，也可能超过4GB的物理内存。首先32位操作系统提供的地址空间还是4GB，这是因为32位操作系统运行在32位cpu上，而32位cpu寄存器地址是32位的。寄存器的主要目的是存储地址，比如PC寄存器存的是当前进程下一条要执行指定的地址，这个地址是当前进程地址空间的地址，由于寄存器最大存32位，所能表示的地址范围最大就是4GB，因此32位操作系统下地址空间也是4GB，另外32位操作系统提供的指针大小也是32位，指针指向的也是地址空间的地址。而对于物理地址线也是32条的cpu，其物理寻址能力也只能是4GB，因为cpu的物理地址线就像是cpu与外界通信的渠道，cpu想取值，需要将地址传给内存控制器，一个物理地址线能传递1bit的信息，因此32位物理地址线导致cpu访问物理内存的限制只能是4GB。但后续部分32位cpu引入PAE（Physical Address Extension）支持更多的物理地址线，如36位物理地址线，那能访问的物理内存范围就变为了64GB。但寄存器的大小还是32位，如果寄存器存的地址是地址空间地址，那32位是够用的，但如果存的是物理地址，如页表寄存器或页目录寄存器，那物理地址现在是64GB，32位寄存器不够用，操作系统的做法是对于一些需要直接存入寄存器的物理地址，他所在的位置不能超过前4GB地址，这样32位就够用了。比如按上例，当前某个进程它的PC寄存器是0X12345678，那先取为VPN0X12345，根据寄存器存的页表地址（或页目录地址），查询VPN0X12345对应的PFN，而PFN是24位，假设是0X654321，则PFN+页内偏移量 = 0X654321678就是物理地址，这个物理地址就是36位的，CPU通过自己36位的物理地址线向内存控制器发送该地址，就可以读取64GB物理内存空间下的某一处的数据。注：这里只是一个示例，实际引入PAE后页表条目可能也变为了8字节64位，不再是32位。
Netconf和Cmd近期问题定位整理笔者目前工作在一家云计算公司，主要业务是云网络。年初的时候，公司有一些功能的重构，需要一个用Java写一个与设备进行通信的RPC框架，笔者独自负责这个框架的技术选型与开发。所谓的设备其实就是一些交换机或路由器。通信的方式是通过netconf和cmd(ssh)协议。0. 前言2024-12-30 有这样一个报错：org.apache.sshd.common.io.WritePendingException: A write operation is already pending; cannot write 18 bytes。 服务在使用CMD命令向设备10.230.10.94发送命令的时候，出现了如下错误：[ERROR] 15:29:16.894 [XNIO-1 task-23] [Ny6AcwwR7TiTUQCJVbcBMQgAvOsFqr] c.u.n.v.c.c.s.s.BaseSshSessionSender - 发送给设备10.230.10.94,10.230.10.94:22 发送失败,请求详情:[请求配置={senderType=CMD, timeout=PT10M, responseClass=class java.lang.String, expectedEnd=[]], expectedEndRegex='null', queueName='UCA_NETCONF_CMD_DEFAULT_QUEUE_INDEX', messageIdPrefix='null'}, 请求报文=irf topo-domain 0 , 响应结果=null],失败原因: org.apache.sshd.common.io.WritePendingException: A write operation is already pending; cannot write 18 bytes错误出现的地方在core-drvier服务的BaseSshSessionSender#send第238行，其代码如下：IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes));这行代码是发送操作，其中参数bytes是要发送的内容，已经做了序列化。上述内容出错的原因是：出现了同时调用当前channel的writeBuffer的操作（或者可以解释为：上一条指令的writeBuffer还没操作完，这时候又调了writeBuffer）。1. 问题定位理论上不应该出现这个问题才对，因为本框架是严格按照指令串行设计的。要想定位这个问题，不妨再细追下日志：我把上述日志分为了T1 ~T5 五个时间点1.1 相关代码要想深入分析这些问题，需要对发送流程有一些了解，我们先来看正常的代码流程：业务操作：deviceTemplate.cmd("10.230.10.94","irf priority 1","]").get(); deviceTemplate.cmd("10.230.10.94","irf topo-domain 0","]").get();其中cmd指令会返回一个future，而.get()会同步阻塞等待指令的返回。业务层调了cmd来发送指令后，会先进入：AbstractDevice#sendpublic DeviceFuture send(Object request, RequestConfig requestConfig) { //构造请求上下文 RequestContext requestContext = getRequestContext(request, requestConfig); log.info("收到发送请求，要发送的设备:{},请求详情:{}",this.metaData.getDeviceId(),requestContext); //判断是否能发送（一般只能队列里没有其他指令当前指令才能发送，因此是在这里保证了串行发送） if (checkCanSend(requestContext)) { //如果能发送就直接发送 doSend(requestContext); } //... return requestContext.getDevicePromise(); }如果可以发送，最终会进入：BaseSshSessionSender#send的如下方法：public Boolean send(RequestContext context) { //编码 序列化 byte[] bytes = encode(context); //发送 IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes)); //等待发送完成 boolean written = writeFuture.verify().isWritten(); //判断是否发送成功 if (written) { log.info("发送给设备{},{}:{} 发送成功,请求详情:{}", this.device.metaData().getDeviceId(), this.device.metaData().getDeviceIp(), this.metaData.getPort(), context); return true; } //... }整体流程大致如上，先序列化再发送。我们选用的是NIO客户端，响应也是采取异步监听的，响应的代码大致如下：//一旦设备session返回任何数据都会触发当前函数的调用 public void operationComplete(IoReadFuture readFuture) { Buffer buffer = null; try { //读取数据 readFuture.verify(READ_TIMEOUT); buffer = readFuture.getBuffer(); byte[] bytes = new byte[buffer.available()]; buffer.getRawBytes(bytes, buffer.rpos(), buffer.available()); responseBuilder.append(new String(bytes, StandardCharsets.UTF_8)); //由于ssh是基于tcp的，而tcp是流协议，因此需要我们自己拼包拆包 //这里是判断设备session返回的数据是否到了一个完整的包 if (isEnd()) { String package = assemblyPackage(); if (package != null) { //处理这个响应包 handleResponse(package); } } } // ... }protected void handleResponse(String response) throws Exception { //拿到这个响应对应的之前下发的请求 RequestContext context = this.sender.device.peek(); if (context != null) { //反序列化 解码 Object result = context.getSender().decode(response.getBytes(), context.getRequestConfig().getResponseClass()); //... //设置结果 context.getDevicePromise().setResult(DeviceBaseResponse.success(result)); log.info("设备{},响应完成,请求和响应详情:{}", context.getSender().device().metaData().getDeviceId(), context); } //.... }目前我们的异步采用future-promise，正常情况下，对于一个已发送的指令的结果获取可以有两种办法：通过.get()同步阻塞等待响应的返回通过.addListener()异步监听指令的返回因此这也就意味着，一旦指令真的返回，框架层至少需要做两件事：唤醒因为.get()而阻塞的线程，让其拿到结果，继续向下执行执行为这个指令注册的监听器函数其核心代码在 DefaultPromise#doSetResult()/** * 设置结果，核心方法 * 思路主要为：通过CAS加原子引用设置值，保证线程安全(避免多线程一起设置返回结果) * 一旦结果设置完成，代表当前任务的完成，此时就需要做两件事： * 1. 唤醒因此而阻塞的线程 * 2. 执行监听器的回调方法 * @param objResult 结果 * @return 返回是否设置成功 */ private boolean doSetResult(Object objResult) { //只有 result == null 此时代表还没有人设置，那么此时可以设置result if (RESULT_UPDATER.compareAndSet(this, null, objResult)) { //核心功能：唤醒阻塞线程并且执行回调监听器 if (checkNotifyWaiters()) { //执行异步方式注册的监听器 notifyListener(); } return true; } return false; } protected synchronized boolean checkNotifyWaiters() { if (waiters > 0) { //这里是唤醒由于await/get等 而阻塞的线程 notifyAll(); } return !listeners.isEmpty(); } /** * 核心方法 回调执行监听器的方法 * 会在任务一旦完成时调用 */ protected void notifyListener() { List<GenericFutureListener<?>> newListeners; //.... for (;;) { notifyListener0(this, newListeners); //... } } protected void notifyListener0(Future future, List<GenericFutureListener<?>> listeners) { try { //如果有可执行器，则用可执行器执行回调，否则用当前唤醒线程执行回调 if(executor == null){ //... }else { //这里采用的执行监听器策略是：由一个线程来顺序执行监听器，而非多个线程并行执行监听器 //这是因为我们要保证一些监听器要优先于另一些监听器先执行 executor.execute(()->{ for(GenericFutureListener l:listeners){ try { l.operationComplete(result); } catch (Exception e) { log.error("监听器回调执行失败，失败详情:",e); } } }); } } catch (Throwable t) { log.error("监听器回调执行失败，失败详情:",t); } }也即一条指令的发送流程大致如下：通过DeviceTemplate Api请求发送某个指令，可以.get()等待返回，也可以.addListener()注册监听器异步监听，执行步骤2到达AbstractDevice#send() 判断指令是否可以发送，一般不能发送是因为队列内还有其他指令，如果不能发送，直接返回future。如果能发送（队列内没有其他指令），执行步骤3到达BaseSshSessionSender#send()，真实的下发指令，然后返回future。等设备响应指令或指令超时后，认为指令完成，执行指令完成操作，唤醒因为当前指令.get()而阻塞的线程和执行为当前指令注册的监听器。这里的步骤2，对于此刻不可下发的指令（队列内还有其他指令），这些指令会由其前置节点完成后下发。假设指令1未返回，这时候指令2进来了要请求下发，那指令2此刻就只能入队列，不能真的发下去。当指令1完成后（设备返回或超时），再下发指令2。那这个流程是如何实现的呢？其实是用了上面说的.addListener()，也即除了业务人员可能会用.addListener()外，框架还会为每个要发送的指令单独注册一个监听器，其代码在：DefaultDevice#doSendBefore()/** * doSendBefore方法是在请求发送前的拦截方法 * 这是队列串行发送的核心实现方法，在这里主要需要实现串行链式发送功能 * 链式调用是在future的监听器中实现，因为一旦future完成代表请求完成，此时就需要做如下事情： * 1. 将自己从队列中删除 * 2. 调用新的队列头节点下发 * * @param requestContext 请求上下文 * @return true 发送 false 取消发送 */ @Override protected boolean doSendBefore(RequestContext requestContext) { //核心功能：当前请求发送并且返回后（无论是正常返回还是异常返回） 需要做如下事情： // 1.将自己从当前队列中移除 // 2.判断是否有可发的继任节点（真正的头节点），如果有就需要唤醒继任节点的下发 requestContext.getDevicePromise().addListener(result -> { RequestContext next; synchronized (requestContext.getRequestQueue()) { //将自己从队列内移除 requestContext.getRequestQueue().poll(); //取出队列里的新的头节点 next = requestContext.getRequestQueue().peek(); if (next != null) { next.setStatus(RequestContextStatus.WAIT_FOR_SEND); } } //如果头节点存在代表需要下发继任节点 if (next != null) { log.info("DefaultDevice 触发chain发送：设备：{},请求配置：{}",this.deviceId,next); doSend(next); } }); return super.doSendBefore(requestContext); }因此按上面的例子指令1下发的时候会为他注册这个监听器。当指令1返回后，我们会执行指令1的监听器，这时候发现队列内还有其他未下发的指令（指令2），指令1的监听器会执行下发操作，这时候就把指令2下发了。1.2 问题定位了解了上述代码流程后，就可以定位问题了：我们先只看T1、T2和T4这四个时间点，这四个时间都是发送irf priority 1指令，但其实这个日志的执行很诡异：T1打印日志的点在AbstractDevice#send()，T2是指令已经返回了，而T4却是BaseSshSessionSender#send()指令发送成功，也即指令的返回日志比指令的发送成功日志先打印，一条指令返回比发送成功还快。这其实就是问题所在，我们把BaseSshSessionSender#send()的代码拿过来就知道了：byte[] bytes = encode(context); IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes)); boolean written = writeFuture.verify().isWritten(); if (written) { log.info("发送给设备{},{}:{} 发送成功,请求详情:{}", this.device.metaData().getDeviceId(), this.device.metaData().getDeviceIp(), this.metaData.getPort(), context); return true; }其中发送成功的打印在上面代码第5行，第2行是发送，第3行是等待发送成功。很明显，上面的T4对应第5行，那不妨把第2行IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes));认为是T1.5，而第3行boolean written = writeFuture.verify().isWritten();认为是T3.5。那就是T1时刻，AbstractDevice#send()打印收到发送请求，T1.5发送了请求，T3.5得到请求发送成功的结果，T2设备返回了，T4打印第五行，发送成功。我们不妨认为真正的发送成功是T1.8。也就是说T1.5我们发送了请求后，T1.8 发送成功，T2设备就返回了，只是T3.5我们才拿到发送成功的通知，然后T4打印了发送成的日志。上述这种情况其实也合理，首先是发送和响应是两个完全不同的线程。T1.8发成功后设备响应很快，立即就返回了，触发了返回的监听处理。而第三行时间是T3.5是因为我们采用了阻塞等待发送，发送会阻塞（因为IO），发送完成后再唤醒阻塞线程，但我们知道线程的唤醒和调度是不可预测的，那很可能我们T1.5阻塞发送，T1.8发送成功然后唤醒我们的发送线程，T2收到了设备的返回，但直到T3.5之前的发送线程才被唤醒执行。因此整个流程可以梳理为：T0时刻：业务人员调用deviceTemplate.cmd("10.230.10.94","irf priority 1","]").get();发送请求T1时刻：AbstractDevice#send()收到请求，因为队列内无其他指令，因此直接发送T1.5时刻：请求发送T1.8时刻：请求发送完成T2时刻：设备响应请求，一旦设备响应了请求，我们就执行两个内容：1. 唤醒因为这个请求而阻塞线程 2. 执行这个请求注册进来的监听器。T3时刻：由于阻塞线程被唤醒，业务人员知道了自己的指令响应成功，继续发送deviceTemplate.cmd("10.230.10.94","irf topo-domain 0","]").get();，然后AbstractDevice#send()打印收到了irf topo-domain 0的请求T3.2时刻，irf topo-domain 0的发送请求走到了BaseSshSessionSender#send()，但由于上一条指令irf priority 1的发送完成在T3.5时刻才唤醒，所以存在上一条指令还未完成，下一条指令就发送的情况T3.5 irf priority 1的发送阻塞线程被唤醒T4打印日志发送irf priority 1成功T5时刻 由于在T3.2~T3.5间调用IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes));准备irf topo-domain 0进行实际的发送，但由于上述原因，报了错，因此打印出错误日志。这便是上述整个错误的梳理过程，虽然我们上面写了那么多Tx时刻，但实际从T2到T4都是同一毫秒，而从T1到T5整个时间也不过是3毫秒。再加上代码采用了多处异步，NIO和多线程，且涉及到诸多线程的挂起和唤醒，而这些调度又是不可预测的因此定位起来其实挺困难的。2. V1版解决方案定位清楚上述问题后，我们就很容易明白，其实是由于发送这个流程不可同时进行，必须要等到上一条指令完全发送完成，才能下发下一条指令。因此笔者在V1版本的解决方案很简单，就是对BaseSshSessionSender#send方法加了把同步锁。public synchronized Boolean send(RequestContext context) { //... }3. V1版问题很快上述V1版本就测出了死锁的bug。要聊这个新的bug，我们还回顾下之前的发送流程：deviceTemplate.cmd("10.230.10.94","irf member 2","]").get(); deviceTemplate.cmd("10.230.10.94","irf priority 1","]").get(); deviceTemplate.cmd("10.230.10.94","irf topo-domain 0","]").get();上述发送流程先发送irf member 2，等irf member 2返回后，再发送irf priority 1，同理等irf priority 1返回后再发送irf topo-domain 0。 我们之前聊过，本框架严格限制了串行发送，对于要发送的指令，如果队列内没其他指令，则可以直接发送；但如果队列内有别的指令，则当前指令入队列且等上一个指令完成后，由上一个指令的监听器函数执行发送操作。那我们现在就有个问题：理论上指令是在返回后才发的下一条，那这时候应该没有等待的指令了，这时候发送irf priority 1或者irf topo-domain 0是可以直接发送的吗？还是由上一个指令的完成后的监听器发送的呢？答案是不一定。我们可以再看下日志其中XNIO-1 task-23是业务层调用的线程，而pool-21-thread-12是线程池执行监听器的线程。因此irf priority 1没有直接发送，而是由它的上一个指令irf member 2返回后监听器里发送的，而irf topo-domain 0是直接发送的。这是为什么呢？这里我们不妨再把唤醒阻塞线程和执行监听器的代码拿过来：/** * 设置结果，核心方法 * 思路主要为：通过CAS加原子引用设置值，保证线程安全(避免多线程一起设置返回结果) * 一旦结果设置完成，代表当前任务的完成，此时就需要做两件事： * 1. 唤醒因此而阻塞的线程 * 2. 执行监听器的回调方法 * @param objResult 结果 * @return 返回是否设置成功 */ private boolean doSetResult(Object objResult) { //只有 result == null 此时代表还没有人设置，那么此时可以设置result if (RESULT_UPDATER.compareAndSet(this, null, objResult)) { //核心功能：唤醒阻塞线程并且执行回调监听器 if (checkNotifyWaiters()) { //执行异步方式注册的监听器 notifyListener(); } return true; } return false; } protected synchronized boolean checkNotifyWaiters() { if (waiters > 0) { //这里是唤醒由于await/get等 而阻塞的线程 notifyAll(); } return !listeners.isEmpty(); } /** * 核心方法 回调执行监听器的方法 * 会在任务一旦完成时调用 */ protected void notifyListener() { List<GenericFutureListener<?>> newListeners; //.... for (;;) { notifyListener0(this, newListeners); //... } } protected void notifyListener0(Future future, List<GenericFutureListener<?>> listeners) { try { //如果有可执行器，则用可执行器执行回调，否则用当前唤醒线程执行回调 if(executor == null){ //... }else { //这里采用的执行监听器策略是：由一个线程来顺序执行监听器，而非多个线程并行执行监听器 //这是因为我们要保证一些监听器要优先于另一些监听器先执行 executor.execute(()->{ for(GenericFutureListener l:listeners){ try { l.operationComplete(result); } catch (Exception e) { log.error("监听器回调执行失败，失败详情:",e); } } }); } } catch (Throwable t) { log.error("监听器回调执行失败，失败详情:",t); } }可以看到我们是先唤醒的阻塞线程，再在线程池里执行的监听器方法的，但不要就那么以为阻塞线程的执行一定比监听器方法线程的执行靠前。因为唤醒阻塞线程，阻塞线程什么时候调度是不可预测的，同理启用一个新的线程执行监听器方法，这个新的线程什么时候调度也是不可预测的。这就好像你建了两个线程，分别启动他们，但这两个线程到底哪个先执行是不一定的。如果监听器的执行线程优先于被唤醒的阻塞线程，那就是监听器方法执行的时候会先把自己（当前指令）从队列内移除。随后被唤醒的阻塞线程执行的时候，会下发下一条指令，因为队列内无其他指令所以可以直接下发。如果被唤醒的阻塞线程优先于监听器的执行线程，那就是被唤醒的阻塞线程下发下一条指令，但由于监听器方法还未执行，所以指令并未从队列内删除，因此不满足发送条件，入队列后就直接返回。随后，监听器方法执行的时候将自己从队列内移除后，发现还有需要继续发送的指令，所以会由它来下发这条指令。了解了这些后，我们再来对正常发送流程做个补充：我们与设备间维持的连接不会主动断，但如果一段时间不发送请求，这个连接可能会被设备端断掉。如果连接断掉，我们并不会重连，只有在有业务需要发送的时候才重连。所以发送流程会先检查当前连接是否可用，如果是断连状态，它会进行一次重连，重连上后再发送请求。下面是BaseSshSessionSender#send()的代码public synchronized Boolean send(RequestContext context) { try { //如果是不自动重连的服务，判断断开连接后，主动重连下 if (!this.metaData.isAutoReconnect()) { if (this.status == SenderStatus.DISCONNECT || this.session == null || this.session.isClosed() || this.session.isClosing()) { this.connect(); } } //... }可以看到我们会调用this.connect()做重连。重连除了连接外，连接完成后还会发送握手报文，握手成功后才可以继续下发请求，而握手报文的发送，也是调的BaseSshSessionSender#send()。也即业务端调send()，send()发现需要重连调connect()，connect()连接成功后调send()发送握手报文，握手成功后send()才能继续走下去发送实际的业务端请求。对于Netconf握手报文只有一条；但对于CMD，握手报文有两条（对于CMD为什么会有握手报文和为什么需要两条这个问题，原因很复杂，这里不展开）。对于CMD，想象下，假设有一个业务请求进来：deviceTemplate.cmd("10.230.10.94","irf member 2","]").get();我们假设客户端的请求线程是BUSINESS-1这时候会调到BaseSshSessionSender#send()，上面我们说过V1版本send()加了同步锁synchronized，因此BUSINESS-1会持有这个锁。然后进入send()发现设备是断连的，会调用connect()，connect()做连接，连接成功后发送握手报文，假设握手报文是这样发送的：device.send("握手报文1","]").get(); device.send("握手报文2","]").get();那么握手报文1先发送，调到BaseSshSessionSender#send()，由于现在整个线程还是BUSINESS-1且synchronized是可重入锁，所以可以进入send()并发送握手报文。等握手报文1返回后，发送握手报文2，这时就存在一个问题：握手报文2不一定还是BUSINESS-1线程直接发送了，有可能是握手报文1的监听器执行线程发送的，原因我们上面解释过了。如果是监听器执行线程发送，那握手报文2调到BaseSshSessionSender#send()的时候，由于锁被BUSINESS-1持有且没释放，那就会一直阻塞。这样就会存在握手报文2发送拿不到锁一直阻塞，而BUSINESS-1持有锁但一直要等待握手报文2完成才能继续执行的情况，这就是加了synchronized后CMD发送有概率死锁的原因。4. V2版解决方案了解了原因后也很容易想到解决方案，缩小锁的加锁范围就好了。也即最初问题其实只是IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes)); written = writeFuture.verify().isWritten();这两步不能同时执行，那单独加这两步就好，因此V2的解决方案：public Boolean send(RequestContext context) { //编码 序列化 byte[] bytes = encode(context); boolean written; synchronized (this){ IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes)); written = writeFuture.verify().isWritten(); } //判断是否发送成功 if (written) { log.info("发送给设备{},{}:{} 发送成功,请求详情:{}", this.device.metaData().getDeviceId(), this.device.metaData().getDeviceIp(), this.metaData.getPort(), context); return true; } //... }
Apache mina-sshd 使用中的一些坑笔者目前工作在一家云计算公司，主要业务是云网络。年初的时候，公司有一些功能的重构，需要一个用Java写一个与设备进行通信的RPC框架，笔者独自负责这个框架的技术选型与开发。所谓的设备其实就是一些交换机或路由器。通信的方式是通过netconf协议，而netconf基于ssh，因此需要一款合适的ssh客户端，笔者最终选择的是Apache mina-sshd，但使用过程中遇到了不少坑，mina的文档又非常的少，所以在这里记录下踩过的一些坑。首先在技术选型的时候，笔者一直想的是需要一个NIO的客户端，这很重要。在Java下NIO首先想到的是Netty，但netty没有实现默认的ssh协议，自己从零写一版是完全不现实的。除了netty，还剩mina，这俩框架的其实都出自Trustin Lee之手，但这两个框架的区别还是非常大的。首先最大的区别就是生态，netty的资料很多很多，最经典的是其核心贡献者Norman Maurer写的Netty in Action。但mina就不同了，你找遍全网，也很难找到靠谱的mina文档，唯一的文档可能就是官网下的几个md，可以说是写的非常非常不靠谱了，因为作为一个基本的NIO框架，竟然完全没写怎么如何响应式（异步）地读取和写入数据，官方demo居然都是阻塞式等待的写法。其次的区别就是API，netty的API设计非常非常的优雅，阅读netty源码的时候，你随时随地都能感受到netty中的asynchronous 和event-driven思想。反观mina却设计的很乱，它似乎也想有异步和事件驱动，但总是这一个入口，那一个入口，又没有详细的文档，导致使用起来十分的困难。笔者淌了很多地坑，所以想在此记录一下这些内容。1. 响应式地读取和写出数据读取public ClientChannel createChannel() throws Exception { //创建netconf的subsystem 详情请见：https://www.rfc-editor.org/rfc/pdfrfc/rfc6242.txt.pdf ClientChannel channel = session.createChannel(ClientChannel.CHANNEL_SUBSYSTEM, NETCONF_SUBSYSTEM_NAME); //设为异步channel channel.setStreaming(ClientChannel.Streaming.Async); channel.open().verify(); //并同时为channel注册一个监听器，用于监听读取事件 channel.getAsyncOut().read(new ByteArrayBuffer(BUFFER_PIPE_SIZE)).addListener(new NetconfChannelResponseListener(this)); return channel; }上面通过sesion创建出channel后，需要将channel的streaming设置为Streaming.Async，并同时为channel注册一个监听器，用于监听读取事件。监听器的主要内容如下：public static class ChannelResponseListener implements SshFutureListener<IoReadFuture> { protected final BaseSshSessionSender sender; /** * 当前可以理解为是一个字节缓冲池，用于承装设备返回的字节，但由于netconf和ssh都是字符型协议，所以这里直接用一个StringBuilder来作为缓冲池 */ protected StringBuilder responseBuilder; public ChannelResponseListener(BaseSshSessionSender sender) { this.sender = sender; this.responseBuilder = new StringBuilder(); } /** * 一旦设备响应，触发当前方法 * * @param readFuture 读future */ @Override public void operationComplete(IoReadFuture readFuture) { Buffer buffer = null; try { readFuture.verify(READ_TIMEOUT); buffer = readFuture.getBuffer(); byte[] bytes = new byte[buffer.available()]; buffer.getRawBytes(bytes, buffer.rpos(), buffer.available()); responseBuilder.append(new String(bytes, StandardCharsets.UTF_8)); // 下面是针对拆包的处理，如netconf发送的响应结尾是 ，则代表一条完整报文响应完成，否则代表还未响应完成，继续监听响应 if (isEnd()) { String netconfPackage = assemblyPackage(); if (netconfPackage != null) { handleResponse(netconfPackage); } } } catch (Exception e) { log.error("设备{},响应处理失败,失败原因:", sender.device().metaData().getDeviceId(), e); } finally { if (buffer != null) { buffer.rpos(buffer.rpos() + buffer.available()); buffer.compact(); } this.sender.channel.getAsyncOut().read(buffer).addListener(this); } } }其核心就是一旦收到对端发来的数据，mina会自动执行我们监听器的operationComplete方法，然后我们从IoReadFuture中读取数据，这类似于netty的ChannelInboundHandler中的channelRead事件。assemblyPackage与isEnd和handleResponse涉及到具体的业务处理，这里不再给出。这里的写法参考自项目BroadbandForum/obbaa-netconf-stack: A NETCONF stack for use in OB-BAA implementations (github.com)，具体内容为obbaa-netconf-stack/netconf-client/src/main/java/org/broadband_forum/obbaa/netconf/client/dispatcher/SshClientDispatcherImpl.java at master · BroadbandForum/obbaa-netconf-stack (github.com)obbaa-netconf-stack/netconf-client/src/main/java/org/broadband_forum/obbaa/netconf/client/ssh/SshNetconfClientSession.java at master · BroadbandForum/obbaa-netconf-stack (github.com)obbaa-netconf-stack/netconf-client/src/main/java/org/broadband_forum/obbaa/netconf/client/ssh/SshHelloMessageListener.java at master · BroadbandForum/obbaa-netconf-stack (github.com)obbaa-netconf-stack/netconf-client/src/main/java/org/broadband_forum/obbaa/netconf/client/ssh/SshNetconfClientSessionListener.java at master · BroadbandForum/obbaa-netconf-stack (github.com)写出IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes));如上是异步地写出，mina返回一个future，开发人员可以自己为future写一个监听器来监听是否写出成功。坑这里的坑其实在读取，上面可以看到我们其实是为一个channel注册了一个监听器，只要这个channel存在就一直使用这个监听器，但实际上，笔者一开始地写法是每次发送地时候注册一个监听器，这个监听器单独处理这个发送，大概写法如下：public void send(RequestContext context) { //注册监听器 channel.getAsyncOut() .read(new ByteArrayBuffer(BUFFER_PIPE_SIZE)) .addListener(new NetconfChannelResponseListener(context)); byte[] bytes = encode(context); IoWriteFuture writeFuture = this.channel.getAsyncIn().writeBuffer(new ByteArrayBuffer(bytes)); }这样每次发送的时候其实都会为channel重新注册一个监听器，一个监听器只负责一次收发，看起来很好，非常的解耦。但实际上这样写在连续发送的时候会报异常，所谓连续发送是指上一条请求发送完，对端还没响应完，还在读取对端的数据，这时候下一条指令又发送了。2. 线程池在创建客户端的时候，我们的代码往往是这样的：protected void doConnect() throws Exception{ //建立连接 this.client = SshClient.setUpDefaultClient(); this.client.start(); ConnectFuture connectFuture = client.connect(metaData.getUserName(), metaData.getIp(), metaData.getPort()) .verify(metaData.getConnectTimeout()); if (connectFuture.isConnected()) { this.session = connectFuture.getSession(); } else { this.status = SenderStatus.DISCONNECT; throw new ConnectException(connectFuture.getException().getMessage()); } this.session.addPasswordIdentity(metaData.getPassword()); AuthFuture authFuture = this.session.auth().verify(metaData.getAuthTimeout()); if (!authFuture.isSuccess()) { throw new AuthException("认证失败:" + authFuture.getException().getMessage()); } this.channel = createChannel(); this.status = SenderStatus.CONNECTING; }坑1创建SshClient的时候会默认创建cpu*2个线程，这些线程用于处理连接的读写等事件，类似于netty中的IO线程EventLoopGroup。假设我们有100个要连接的服务端，当前服务器是8核CPU，则项目启动就会创建100*8*2 = 1600个线程，如果我们与对端服务端通信并没有很频繁，那这1600个线程是非常非常浪费资源的。目前笔者并未在官方文档中找到创建SshClient的时候指定要创建的IO线程个数的代码，从这里也可以对比出，不如netty的new NioEventLoopGroup(1)简洁方便。坑2这是一个很大的坑，会存在内存泄露问题。内容如下：为保证与设备的长连接，以及尽可能地缩短与设备连接的不可用时间，笔者在设计框架的时候，让客户端自动重连，大致代码如下：this.session.addSessionListener(new SessionListener() { @Override public void sessionClosed(Session session) { synchronized (BaseSshSessionSender.this){ if(BaseSshSessionSender.this.status != SenderStatus.DISCONNECT && BaseSshSessionSender.this.status != SenderStatus.DEATH){ BaseSshSessionSender.this.status = SenderStatus.DISCONNECT; BaseSshSessionSender.this.reConnect(true); } } } });protected void reConnect(boolean immediately){ this.scheduledExecutorService.schedule(this::connect, immediately?0L:metaData.getReconnectInterval().getSeconds(), TimeUnit.SECONDS); } public void connect() { try { if(this.status != SenderStatus.DEATH){ //doConnect代码见上面 this.doConnect(); } } catch (Exception e) { this.status = SenderStatus.DISCONNECT; this.reConnect(false); } }首先如果连接失败会隔一段时间再重连，另外为session注册一个监听器，一旦连接失败也会触发重连。由于重连会重新创建客户端，因此又会重新申请cpu*2个线程。但问题是，之前我们申请过的cpu*2个线程竟然没销毁！！！ 以笔者现在的服务为例，项目启动的时候总线程数大概在800多个，但由于有几个ssh服务端一直连不上，因此会触发重连，大概过了不到1小时就涨到了10k左右的线程数。另外整个服务的内存也在缓慢的增长，项目启动的时候大概占用1.2GB的内存，启动不到1小时后，内存就涨到了2.6GB。另外由于创建的线程越来越多，线程上下文的切换也会越来越耗时，这会导致服务cpu的消耗也越来越高，以笔者当前的开发环境为例，Win10，i7-6700，24GB内存，项目启动的时候，大概800个线程，服务cpu占用率不到10%（5%-8%波动）。当不到一小时后，线程达到10k个线程，此时服务cpu占用率高达近20%。针对坑1和坑2，目前的改动是使用单例的SshClient，由于这里的SshClient内建了一组线程池，且一个SshClient可以创建多个session，因此采用所有session都用同一SshClient，重连也用的这个SshClient，不会再新建线程，自然就不会再内存泄露。//单例Client public static SshClient CLIENT = SshClient.setUpDefaultClient(); static { CLIENT.start(); CLIENT.setGlobalRequestHandlers(Arrays.asList(KeepAliveHandler.INSTANCE, NoMoreSessionsHandler.INSTANCE)); }protected void doConnect() throws Exception { //使用单例的Client ConnectFuture connectFuture = CLIENT.connect(metaData.getUserName(), metaData.getIp(), metaData.getPort()) .verify(metaData.getConnectTimeout()); //... }这样相当于所有连接，都共用一组线程池。无论你有100个还是10000个ssh 连接，数据的读写都用的是这组cpu*2个线程。这就对代码有一个要求：不能阻塞这组IO线程！！！上面的代码中，笔者用这组IO线程主要是做拼包拆包、反序列化等工作，基本都是CPU操作，所以不存在阻塞的点。坑3这个倒不算是坑，只是对比netty，设计上的一个不同：mina的IO线程并不绑定channel。如果你熟悉netty，会知道对于netty而言，一个channel会唯一绑定一个IO线程，这样的好处是可以避免发生上下文切换。比如我们一个需求场景是：收到消息后，将消息尾追加一个hello字符串响应给发送者。由于netty会将channel与IO线程绑定，所以我们可以在一个线程里处理：接收消息、尾追加hello字符串、写出消息。整个处理无上下文切换，效率很高。对于这段代码：public void operationComplete(IoReadFuture readFuture) { Buffer buffer = null; try { readFuture.verify(READ_TIMEOUT); buffer = readFuture.getBuffer(); byte[] bytes = new byte[buffer.available()]; buffer.getRawBytes(bytes, buffer.rpos(), buffer.available()); responseBuilder.append(new String(bytes, StandardCharsets.UTF_8)); // 下面是针对拆包的处理，如netconf发送的响应结尾是 ，则代表一条完整报文响应完成，否则代表还未响应完成，继续监听响应 if (isEnd()) { String netconfPackage = assemblyPackage(); if (netconfPackage != null) { handleResponse(netconfPackage); } } } catch (Exception e) { log.error("设备{},响应处理失败,失败原因:", sender.device().metaData().getDeviceId(), e); } finally { if (buffer != null) { buffer.rpos(buffer.rpos() + buffer.available()); buffer.compact(); } this.sender.channel.getAsyncOut().read(buffer).addListener(this); } }mina每次响应式的读取数据都是从SshClient创建的线程池中随机选一个线程。这会有什么影响呢？由于netty channel绑定一个唯一的线程，那其实我们就可以认为负责这个channel的IO线程里的操作其实都是线程安全的，比如为netty注册的很多InBoundHandler和OutBoundHandler我们往往不需要考虑线程安全问题，因为这些方法只会在一个线程执行。但很明显mina就不同了，如果你能确定自己的执行方法是在IO线程内，你需要仔细考虑线程安全问题。
1. Java FutureFutureTask在说FutureTask之前先说下Task，对于Java语言，Task代表一个可执行任务，而一个可执行任务往往是继承自Runnable接口或Future接口假设如果我们直接交由一个线程执行Runnable，那么我们既不能停止这个任务也不能看到当前任务得到当前任务是否完成，如果完成我们也得不到执行结果，因此Java就抽象出一个接口，这个接口叫FutureFuture表示一个异步执行的情况的结果，我们可以对这个cancel还可以get结果还可以判断它是否执行完成或是否取消等FutureTask就是结合了Runnable和Future，即对一个可运行的任务，可以执行上述说的，查询结果或查看状态。2. Netty Future基本的Java Future前面已经说了，但基本的Java Future接口存在问题，表面上是异步，但实际想在执行完成后做些什么还得是同步的，即需要阻塞等待执行完成。也即传统的Java Future其问题是虽然可以得到当前执行的情况和获得执行的结果，但都不是真正上的异步，因此Netty扩充了Java Future的Futute功能，其补充功能如下：//判断当前执行是否成功 boolean isSuccess(); //判断当前执行是否取消Future<V> addListener(GenericFutureListener<? extends Future<? super V>> listener); boolean isCancellable(); //判断当前任务是否出错，如果出错返回出错信息 Throwable cause(); //添加监听器，在任务执行完成时回调 Future<V> addListener(GenericFutureListener<? extends Future<? super V>> listener); Future<V> addListeners(GenericFutureListener<? extends Future<? super V>>... listeners); //移除监听器 Future<V> removeListener(GenericFutureListener<? extends Future<? super V>> listener); Future<V> removeListeners(GenericFutureListener<? extends Future<? super V>>... listeners); //同步操作，会一直等到任务执行完成，如果任务执行失败则抛出失败原因 Future<V> sync() throws InterruptedException; //同步操作，等待任务完成，如果任务中断会抛出异常 Future<V> await() throws InterruptedException; //直接返回结果，不阻塞，如果任务未执行完会返回null V getNow();与Java Future最大的不同是Netty的Future多了监听器这一事件，监听器本质是一种回调，用于在任务完成时进行回调。通常而言，异步和回调是成对出现的，也即我们不仅要异步的执行任务，还要通过回调函数在任务执行完后处理结果ChannelFuture在Futute的基础上提供了channel()的操作，也即ChannelFuture认为，其事件必然会绑定一个Channel，通过channel()可以获得当前ChannelFuture绑定的Channel不同的是，Netty不仅引入了Future，还引入了异步思想下的Promise我们之前知道，Netty的future与java的不同有一点是包含isSuccess方法，也即Java的future是只关心干没干完的，到底成没成功我不知道，你别问我，但netty的future是可以返回这个任务是否成功的，因此我们就需要一个对当前任务是否成功的状态设置的功能，这就是Promise接口首先Promise继承自netty的future，也即promise也具有future的这些功能，比如获得结果，注册监听器等其次，promise还具有对结果的设置能力//设置结果为成功 Promise<V> setSuccess(V result); //设置结果为失败 Promise<V> setFailure(Throwable cause);换言之，promise比futute强的地方是可以设置结果的成功或失败，这样在获取结果的时候就可以通过成功或失败来判断而不是是否完成来判断了Netty下的ChannelPromise继承自ChannelFuture和PromiseChannelPromise的一个默认实现类是DefaultChannelPromise但DefaultChannelPromise又继承自DefaultPromise，这个类是一个很关键的类，它实现了很多我们上面说的接口，我们可以看下DefaultPromise做了什么public class DefaultPromise<V> extends AbstractFuture<V> implements Promise<V> { private static final Object SUCCESS = new Object(); private static final Object UNCANCELLABLE = new Object(); private volatile Object result; private Object listeners; }setSuccess操作：@Override public Promise<V> setSuccess(V result) { if (setSuccess0(result)) { return this; } throw new IllegalStateException("complete already: " + this); } private boolean setSuccess0(V result) { return setValue0(result == null ? SUCCESS : result); }setFailure操作：@Override public Promise<V> setFailure(Throwable cause) { if (setFailure0(cause)) { return this; } throw new IllegalStateException("complete already: " + this, cause); } private boolean setFailure0(Throwable cause) { return setValue0(new CauseHolder(checkNotNull(cause, "cause"))); }setValue0:private boolean setValue0(Object objResult) { if (RESULT_UPDATER.compareAndSet(this, null, objResult) || RESULT_UPDATER.compareAndSet(this, UNCANCELLABLE, objResult)) { if (checkNotifyWaiters()) { notifyListeners(); } return true; } return false; }首先可以看到setSuccess与setFailure操作相同就是将返回的结果设置进result字段，不同的是setSuccess设置的是一个V，而setFailure设置的是一个异常类CauseHolder，CauseHolder的封装很简单：private static final class CauseHolder { final Throwable cause; CauseHolder(Throwable cause) { this.cause = cause; } }当我们使用get操作阻塞获得执行结果的时候@Override public V get() throws InterruptedException, ExecutionException { Object result = this.result; if (!isDone0(result)) { await(); result = this.result; } if (result == SUCCESS || result == UNCANCELLABLE) { return null; } Throwable cause = cause0(result); if (cause == null) { return (V) result; } if (cause instanceof CancellationException) { throw (CancellationException) cause; } throw new ExecutionException(cause); }会先判断当前是否完成，如果未完成就挂起等待完成，等到挂起被唤醒就将result返回（此时被唤醒的节点就是当前结果已返回），如果当前已返回，就将result返回，如果运行结果是异常，就抛出这个异常在回到setSuccess与setFailureif (checkNotifyWaiters()) { notifyListeners(); }setValue0里的的这句话有两个意义，首先是唤醒waiters，其次是执行监听器waiters是什么？其实就是等待的线程，我们可以再回来看get的内容：if (!isDone0(result)) { await(); result = this.result; }public Promise<V> await() throws InterruptedException { if (isDone()) { return this; } if (Thread.interrupted()) { throw new InterruptedException(toString()); } checkDeadLock(); synchronized (this) { while (!isDone()) { incWaiters(); try { wait(); } finally { decWaiters(); } } } return this; }private void incWaiters() { if (waiters == Short.MAX_VALUE) { throw new IllegalStateException("too many waiters: " + this); } ++waiters; } private void decWaiters() { --waiters; }可以看到如果当前任务还在运行未返回，就会执行incWaiters和wait操作其中incWaiters就是将当前属性里的waiters数量+1，但是这里会判断waiters的总数量，同时netty会会控制waiters的数量，如果大于Short.MAX(32767)，就会返回异常，即不让那么多线程等待。后面的wait就比较简单了，就是Object下实现的wait函数。当一旦被放行后，代表当前线程不必等待，此时就将waiters--可以看到这其实就是我们常见的锁，锁内有个队列标识当前阻塞的线程。这里需要补充一点的是，很多同学看到synchronized (this) { while (!isDone()) { incWaiters(); try { wait(); } finally { decWaiters(); } } }synchronized关键字，同时在synchronized内又加了wait操作，既然wait了岂不是类似于死锁，其他所有线程无法进入synchronized，这里其实很简单，就是JDK在Object.wait的时候会释放synchronized，这个释放是JVM自动做的，大家不用担心。我们再回来看if (checkNotifyWaiters()) { notifyListeners(); }这两段代码：private synchronized boolean checkNotifyWaiters() { if (waiters > 0) { notifyAll(); } return listeners != null; }这里很简单，就是放行所有的阻塞线程。实现方式就是Object的notifyAll。如果还不了解synchronized与await/notify的使用方式，读者可以类比Lock与Condition这就是执行回调的内容了private void notifyListeners() { EventExecutor executor = executor(); if (executor.inEventLoop()) { final InternalThreadLocalMap threadLocals = InternalThreadLocalMap.get(); final int stackDepth = threadLocals.futureListenerStackDepth(); if (stackDepth < MAX_LISTENER_STACK_DEPTH) { threadLocals.setFutureListenerStackDepth(stackDepth + 1); try { notifyListenersNow(); } finally { threadLocals.setFutureListenerStackDepth(stackDepth); } return; } } safeExecute(executor, new Runnable() { @Override public void run() { notifyListenersNow(); } }); }首先我们这里看的是ChannelPromise，对Netty有了解的同学知道Netty将线程分为IO线程和非IO线程DefaultPromise的构造方法就需要传入一个EventExecutor，代表后续这些回调由谁来执行public DefaultPromise(EventExecutor executor) { this.executor = checkNotNull(executor, "executor"); }而executor()就是返回当前之前在构造方法处传入的EventExecutor，我们先假设这是个IO线程final InternalThreadLocalMap threadLocals = InternalThreadLocalMap.get(); final int stackDepth = threadLocals.futureListenerStackDepth(); if (stackDepth < MAX_LISTENER_STACK_DEPTH) { threadLocals.setFutureListenerStackDepth(stackDepth + 1); try { notifyListenersNow(); } finally { threadLocals.setFutureListenerStackDepth(stackDepth); } return; }private void notifyListenersNow() { Object listeners; synchronized (this) { // Only proceed if there are listeners to notify and we are not already notifying listeners. if (notifyingListeners || this.listeners == null) { return; } notifyingListeners = true; listeners = this.listeners; this.listeners = null; } for (;;) { if (listeners instanceof DefaultFutureListeners) { notifyListeners0((DefaultFutureListeners) listeners); } else { notifyListener0(this, (GenericFutureListener<?>) listeners); } synchronized (this) { if (this.listeners == null) { // Nothing can throw from within this method, so setting notifyingListeners back to false does not // need to be in a finally block. notifyingListeners = false; return; } listeners = this.listeners; this.listeners = null; } } }private static void notifyListener0(Future future, GenericFutureListener l) { try { l.operationComplete(future); } catch (Throwable t) { if (logger.isWarnEnabled()) { logger.warn("An exception was thrown by " + l.getClass().getName() + ".operationComplete()", t); } } }这里就是回调执行的函数了，首先我们需要知道在Netty中IO线程会立即执行当前任务，因此这里回调是立即执行的stackDepth这一信息是Netty为避免回调过深而加的限制，默认最大回调深度是8，大于8的回调不再处理，应该是为了避免栈溢出吧（异步应该不会才对）实际的执行回调的方法是notifyListenersNownotifyingListeners这个属性是用来标识当前是否正在进行通知操作，初始化的时候是false，如果为true代表当前正在通知或已经通知过了，没必要再通知。实际通知的函数是notifyListener0，这个函数很简单，就是执行listener里的operationComplete，我们的回调函数就是继承listener然后里面去写operationComplete函数。当然对于我们常用的sync，也是如此rethrowIfFailed主要是判断结果是否异常，如果异常会抛出异常@Override public Promise<V> sync() throws InterruptedException { await(); rethrowIfFailed(); return this; } private void rethrowIfFailed() { Throwable cause = cause(); if (cause == null) { return; } PlatformDependent.throwException(cause); }监听器的注册也很简单：public Promise<V> addListener(GenericFutureListener<? extends Future<? super V>> listener) { checkNotNull(listener, "listener"); synchronized (this) { addListener0(listener); } if (isDone()) { notifyListeners(); } return this; } private void addListener0(GenericFutureListener<? extends Future<? super V>> listener) { if (listeners == null) { listeners = listener; } else if (listeners instanceof DefaultFutureListeners) { ((DefaultFutureListeners) listeners).add(listener); } else { listeners = new DefaultFutureListeners((GenericFutureListener<?>) listeners, listener); } }只是将它赋值给了ChannelPromise内的listeners属性，不过netty允许对一个事件注册多个监听器，因此listeners是一个Object。有了上面那些内容后，其实就不难理解Netty整套异步流程的实现方式了：首先netty通过Future接口和Promise接口规定了所有异步需要做的工作，主要就是可以注册监听器异步回调也可以阻塞等待获得结果。而ChannelPromise基本实现了这些功能，首先内部的listeners用来盛放当前注册的监听器，setSuccess和setFailure方法用于保证在任务执行完后将执行结果通知future，并且去唤醒那些阻塞等待的线程以及唤醒回调那些注册的监听器。ChannelPromise内部有一个result字段，这个字段是个Object，用于存放结果，如果成功就装成功的结果，如果失败就存放一个异常，ChannelPromise中的失败结果以异常存放。get()和await()以及sync()操作会阻塞直到当前任务执行完，阻塞的方式是通过synchronized和Object.wait实现，而唤醒是通过Object.notifyAll()实现。
聊一聊这个十一十一的时候，大学舍友小宝结婚，我回了趟西安。之所以说回，是因为我大学是在西安读的。宿舍的其他人也基本都到了，上次大家聚齐还是两年前我结婚的时候，只能感慨时光荏苒。再次相聚，熟悉的感觉一下子拉回到大学，当时我们是三室一厅，一个小室4个人，12个人的关系很好，很怀念那段无忧无虑又无所事事的时光。小宝的婚礼办的很好，新娘是我们学校同专业的学姐，当时在团委认识的。后来他们又都保研到了本校研究生，算下来俩人都谈了六七年了，今天也算修成正果。我本来打算是回趟母校的，毕竟毕业四年多也没回母校看看。但也许是近乡情怯，也许是不敢面对，总之我没有去。我参加完小宝的婚礼后，就定了转天早上的机票，匆匆回了天津。在外两三天，看着小宝的婚礼，我只是觉得十分想家，想立刻回去。我那时有种说不出的悲伤和急切。我偶然间在某论坛看到一个同行聊起他的职业生涯，过的很是坎坷，一路爬摸滚打。时间总是过得很快，我都毕业四年多了，我不知道这个行业我未来的出路是什么，感觉到的只有无尽的迷茫和失望。这次出行，我也意识到了一个问题，自己虽然已经毕业四年多，结婚两年多，但一直在忽视身边的人，对待家人一直做的不够，还像个孩子一样自私。自己的身体也发福越来越严重，也开始出现一些健康问题，尤其是今年。忽然有种人到中年的感觉。今年四月份换了份工作，新的工作还算比较满意，但公司的财务不是很好，不知道公司能活多久，自己什么时候会被裁。今年我想，将自己的更多重心放在家庭和自己的身体上，珍惜时间，珍惜眼前人。
简单聊聊DNS本文主要内容源自 计算机网络（原书第7版）本文是笔者在学习的过程中整理的内容1. DNS的功能DNS的主要作用我们都知道是将域名解析为IP地址，这是因为我们人类往往擅长记忆一些名字和字符，而不擅长冗长的数字，但实际的网路连接和路由中又需要具体的IP地址而非无规律的字符。但除此以外，DNS还具备如下功能：主机别名。这主要靠DNS的CNAME记录实现（关于CNAME下面我们会说），比如你现在的服务器域名是c6282k4786.qficp.xyz，这很长很难记住，这时你想换个更简单的域名如coderzoe.com，因为简单的域名更利于你网站的推广，但你又不想老的域名失效（比如有些老用户是使用老域名访问的），此时就可以做到将coderzoe.com域名指向c6282k4786.qficp.xyz。解析邮箱服务。我们常使用的如xxx@qq.com或者xxx@163.com其中qq.com和163.com都是邮箱服务器的域名，这些域名也需要解析为IP地址，因此DNS除了解析Web端的域名还会解析邮箱的域名。这主要靠DNS的MX记录实现（关于MX我们下面也会说）。事实上MX记录允许一个公司的邮件服务器和Web服务器使用相同的域名（如网站https://qq.com和邮箱xxx@qq.com）。负载均衡。一个域名可以对应多个IP地址，当客户端向DNS服务器发起某一域名的查询请求时，如果这个域名对应一个IP集合，那么DNS服务器往往会将这整个集合响应给客户端，但在每个响应中循环这些地址次序。因为客户通常总是向IP地址排在最前面的服务器发送HTTP请求报文，所以 DNS就在这些IP地址集对应的服务器集之间循环实现了负载均衡。2. DNS记录本文不详细展开DNS的报文，只简单讲下DNS记录。DNS记录大致可以概括为一个四元组：（Name，Value，Type，TTL）其中TTL是当前记录的有效时间，我们暂时忽略掉。Type是DNS的类型，Name和Value的取值取决于Type，目前DNS主要有4种Type：Type = A，往往叫A记录。它的Name是域名而Value是该域名对应的IP地址。比如(baidu.com, 172.83.159.98, A)就是一条A记录，它最根本的作用就是提供了域名和IP地址的映射关系，当我们向DNS请求baidu.com的IP地址时，DNS服务器会响应这样一条A记录给我们。Type = CNAME，往往叫CNAME记录。它的Name是域名，而Value也是个域名，比如(coderzoe.com, baidu.com, CNAME)，我将coderzoe.com域名指向baidu.com，这样大家输入https://coderzoe.com响应的就是百度的网站（如果百度服务器未做拦截）。CNAME之所以生效的原因是当客户端向DNS请求coderzoe.com的IP时，DNS会先发现一个CNAME记录(coderzoe.com, baidu.com, CNAME)，然后DNS服务器再查询baidu.com，这时会发现一个(baidu.com, 172.83.159.98, A)的A记录，最后将这个A记录响应给客户端。Type = MX，往往叫MX记录。MX记录主要用于邮箱服务器的解析，它的Name是邮箱的域名，而Value也是个域名。比如(qq.com, mx1.qq.com, MX)。假设我们想通过QQ邮箱xxx@qq.com发送邮件，邮箱客户端会向DNS服务器请求qq.comMX记录，DNS服务器收到请求后，会将(qq.com, mx1.qq.com, MX)记录返回给邮箱客户端，邮箱客户端再向DNS服务器发起mx1.qq.com的A记录请求，DNS服务器会将(mx1.qq.com, 113.96.210.17, A)记录返回给客户端，这样客户端就知道了QQ邮箱服务器的IP地址。我们知道qq.com既是TX公司的邮箱域名也是它的Web域名，那当我们向DNS服务器发起qq.com的请求时，DNS服务器要按邮箱解析还是按Web解析呢？这两种解析情况往往还会对应不同的服务器。为了区别这两种情况，请求客户端会带上自己想要的DNS类型，当进行Web访问的时候，请求就是qq.com+A记录；而进行邮件发送时，请求客户端会发送qq.com+MX记录，得到MX记录后再向DNS发送一次A记录请求，最终得到邮箱服务器IP。Type = NS，往往叫NS记录。NS记录的主要作用是指引DNS的查询路径，告诉客户端去哪里进一步获取查询域的信息。这主要与DNS的工作原理有关，我们来详细的讲一下。3. DNS工作原理我们在上文讲到DNS服务器的主要功能是将域名解析为IP地址。为阐述DNS的工作原理，我们假设DNS采用极其简单的一种设计：在互联网上只有一台DNS服务器，且这台服务器上存储了所有的域名映射规则。当我们通过域名浏览网页或者发送邮件的时候，我们的客户端都会向这台服务器发送请求，最终也都能从这台服务器上得到响应。但很可惜，这是不现实的，因为当今的互联网规模已经十分巨大，没有任何一台单体服务器可以处理如此多的规则与如此频繁的请求。且网络的响应时间与物理距离密切相关，距离这台中心DNS服务器较远的客户端响应延迟会更久，用户体验也会更差。现实中的DNS采用分布式的层次设计，其架构如下图所示：根DNS服务器：存储顶级域名与顶级域DNS服务器关系的DNS服务器。其中顶级域名就是我们域名的最后那一部分，如.com、.cn、.edu等。全球共有400多个根DNS服务器。顶级域DNS服务器：存储当前顶级域名下的域名与权威DNS服务器关系的DNS服务器。如.com顶级域名DNS服务器存储了baidu.com域名的权威DNS服务器的信息。权威DNS服务器：我们之前讲的DNS A记录、CNAME记录和MX记录等都存储在权威DNS服务器上。如(baidu.com, 172.83.159.98, A)这一记录就存储在一个权威DNS服务器上。除此以外，这里还有一个十分重要的DNS服务器：本地DNS服务器。每个ISP（如一个小区或者一个机构的ISP）都有一台本地DNS服务器，当我们的电脑接入ISP时，ISP除了可以为当前电脑分配一个IP地址外，还会为其配置本地DNS服务器的IP地址（通过DHCP协议）。本地DNS的主要功能是代理我们的客户端发请求和缓存DNS记录，我们以一个例子来解释：我们先假设现在你的电脑和你连接的本地DNS服务器上没有任何DNS记录的缓存。你此刻想通过浏览器浏览https://baidu.com，当你输入网址按下回车的时候，整个请求流程如下：你的电脑会作为客户端向本地DNS服务器发起请求，本地DNS服务器收到请求后，会直接向根DNS发起请求，向它询问baidu.com的DNS信息。根DNS收到请求后，查询自己存储的信息，会向本地DNS服务器响应两条DNS记录：一条是NS记录(com, b.gtld-servers.net, NS)另一条是A记录(b.gtld-servers.net, 192.33.14.30, A)。这代表：虽然我不知道baidu.com的具体DNS信息，但我知道存储顶级域名.com信息的DNS服务器，这个服务器是顶级域DNS服务器b.gtld-servers.net，它的IP地址是192.33.14.30，你可以问下它。本地DNS服务器收到响应后再向顶级域DNS服务器192.33.14.30发起请求，查询baidu.com的DNS信息。顶级域服务器192.33.14.30收到请求后，查询自己存储的信息，向本地DNS服务器响应两条DNS记录，一条是NS记录(baidu.com, dns.baidu.com, NS)，另一条是A记录(dns.baidu.com, 110.242.68.134, A)。这代表：虽然我不知道baidu.com的具体DNS信息，但我知道你可以问下权威DNS服务器dns.baidu.com，它应该知道，它的IP地址是110.242.68.134。本地DNS服务器收到响应后再向权威DNS服务器110.242.68.134发起请求，查询baidu.com的DNS信息。权威DNS服务器110.242.68.134收到请求后，查询自己存储的信息，向本地DNS服务器响应一条A记录(baidu.com, 172.83.159.98, A)本地DNS服务器收到响应后将A记录(baidu.com, 172.83.159.98, A)再响应给我们的浏览器客户端，至此我们的客户端得到了baidu.com的IP地址是172.83.159.98。就可以使用这个IP地址做网络连接了。可以看到上述整个流程中本地DNS服务器分别发了三次baidu.com的DNS请求，请求的服务器层级由根DNS到.com顶级域DNS，再到包含baidu.com的权威DNS服务器。通过这样一层层的迭代查找，我们可以一步步精确的定位到存储baidu.com域名详细DNS信息的具体服务器。我们也可以看到NS记录的目的是指引本地DNS服务器查询的路径，告诉本地DNS服务器想进一步获取baidu.com的信息要去哪个服务器。上述流程中还存在两个缓存，本地电脑的缓存和本地DNS的缓存：当你的浏览器收到baidu.com的IP地址后，会将这一信息缓存起来，后续我们再访问baidu.com的时候就不必继续请求本地DNS了。本地DNS会缓存上述所有的查询过程信息。这样当与你连接同一本地DNS服务器的其他用户想访问baidu.com，本地DNS服务器可以直接响应，而无需再请求其他层次的DNS服务器。或者当其他用户想访问qq.com的时候，本地DNS服务器已经知道.com顶级域的IP地址，无需再向根DNS服务器获取，可以直接向.com顶级域DNS服务器查询qq.com的信息。DNS缓存不仅改善了请求的时延性能，还有效地减少了在互联网上不必要的DNS请求，从而降低了网络流量和服务器负载。
JUC源码详细分析之Part4——《线程池》本系列笔记笔者将会详细的讲解JUC中核心类的源码，其中基于的JDK版本为1.8.0_291。由于JUC内容很多，因此笔者将笔记拆为四部分：JUC源码详细分析之Part1——《CAS和原子类型》JUC源码详细分析之Part2——《锁》JUC源码详细分析之Part3——《常见的线程安全集合》JUC源码详细分析之Part4——《线程池》另外，本系列笔记部分内容参考自《深入浅出 Java Concurrency》5. 线程池在讲解线程池前先简单说下线程，我们都知道线程是由操作系统创建的，代表了任务调度的最小颗粒度。Java下使用Thread类代表线程，一个Thread对象就代表一个操作系统所创建线程的引用。Java下创建一个Thread对象与操作系统创建一个线程并不相等，当我们执行new Tnread()时只是创建了一个Thread对象，此时OS并未创建真实线程，但是当我们执行thread.start()时，操作系统会创建并启动一个线程。但是操作系统创建和启动线程属于系统调用，往往系统调用的开销比较大，如果我们频繁的创建线程和销毁线程肯定会降低系统的吞吐量，此时就需要将线程的生命周期与任务的执行解耦，引入线程池来管理线程，避免重复创建/销毁线程时的系统调用开销。5.1 线程池体系简介JUC下线程池体系如下：Executor接口，最顶层接口，只有一个方法void execute(Runable command)，提供了任务提交的基本方法ExecutorService接口，继承了 Executor 接口，最主要的就是获取异步任务执行结果和线程池销毁等方法ScheduledExecutorService 增加了定时任务，周期执行任务的功能AbstractExecutorService 默认实现了ExecutorService的submit()方法并且提供 newTaskFor() 方法将Runnable或Callable转换为RunnableFuture，以便提交给 Executor 执行ThreadPoolExecutor 默认的线程池实现类ScheduledThreadPoolExecutor 默认的带有定时/周期执行任务的线程池实现类线程池的创建是比较复杂的，为解决这一情况，JDK提供了Executors类，用于快速的创建线程池。Executors类下常用API如下：Executors.newCachedThreadPool() 无界线程池，可以进行自动线程回收Executors.newFixedThreadPool(int) 固定大小的线程池Executors.newSingleThreadExecutor() 单个线程的线程池Executors.newScheduledThreadPool(int) 执行定时任务的线程池Executors.newWorkStealingPool(int) 支持并行执行的线程池但是阿里巴巴Java开发手册禁止使用使用Executors创建线程池，其描述如下：【强制】线程资源必须通过线程池提供，不允许在应用中自行显式创建线程。说明：使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销，解决资源不足的问题。如果不使用线程池，有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。【强制】线程池不允许使用Executors去创建，而是通过ThreadPoolExecutor的方式，这样的处理方式让写的同学更加明确线程池的运行规则，规避资源耗尽的风险。说明：Executors返回的线程池对象的弊端如下：1）FixedThreadPool和SingleThreadPool：允许的请求队列长度为Integer.MAX_VALUE，可能会堆积大量的请求，从而导致OOM。2）CachedThreadPool和ScheduledThreadPool：允许的创建线程数量为Integer.MAX_VALUE，可能会创建大量的线程，从而导致OOM。第一个不显示创建线程的问题我们已经说过了；第二个不能用Executors去创建线程池，其与线程池创建时的参数有关，我们下面看一下ThreadPoolExecutor 创建的参数。5.2 ThreadPoolExecutor参数和工作原理public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) corePoolSize：核心线程池的数量。为保证线程可以被复用，我们往往在线程执行完成后不销毁而是暂存起来。但暂存线程也是有内存开销的，因此不能暂存过多，其中corePoolSize是核心线程池的数量，也就是线程池暂存而不会销毁的线程数量。BlockingQueue：任务队列。当核心线程都被用尽的时候，再提交任务的任务会先扔进队列里。这里的队列是阻塞队列。maximumPoolSize：最大线程池数量。当队列也堆积满了，任务实在太多处理不过来的时候，会选择临时创建一些线程来执行新提交的任务，但这些线程也不能过多，总的最大线程数量不能超过maximumPoolSize。keepAliveTime：线程的活跃时间。临时创建的线程执行完任务后也并非立马就销毁，有时如果任务比较多，销毁再建很费时，因此当这些临时线程闲置了keepAliveTime时间还没接到活后就被销毁了。ThreadFactory：线程的构造工厂RejectedExecutionHandler：拒绝策略。当任务多到新开的临时线程都大于maximumPoolSize后，还有任务提交过来，此时就得拒绝任务了，这里可以选择一个拒绝策略来拒绝。因此整个线程池的工作模式如下：我们可以以一个例子来形象的说明这一工作模式：你现在有一家服装制造公司，公司里有一批员工，这批员工常年在公司工作，无论公司有活没活他们都在公司上班，这批员工总数大小就是corePoolSize。我们不妨叫他们核心员工。当公司有活且核心员工还有人闲着的时候，我们就直接选择一个核心员工来干这个活。但如果公司生意比较好，每个人都很忙，再有新活到来的时候，我们往往会选择将订单压一压，先存起来，等手里这批活干完再干存起来的这批。存活的容器就是BlockingQueue。但如果你公司生意真的非常好，存的活太多都存不下了，这时你就想着先招一批临时工应应急。但临时工又不能超太多，多了公司也负担不起，因此临时工+正式工数量不能超过maximumPoolSize就好。如果订单已经存满了，再来新的订单就让那些临时工干吧。临时工干完活后最好不要轻易辞退，万一辞了又有活来还得再招人，挺麻烦的。但临时工又不能一直这样闲养着，要是一段时间他们都没活了就让他们滚蛋吧，这个时间间隔就是keepAliveTime。假设你的厂今年生意异常的好，临时工都招满了，但还是有新订单过来，你一寻思，再来订单一没地方搁二没人接，为了不得罪人家，索性直接早点拒绝吧，因此在这种情况下，你选择了拒绝新来的任务，而这就是RejectedExecutionHandler。不同的是，在上例中我们将工人划分为了核心员工和临时工，在线程池中，线程没有核心与临时的区分，只要线程池数小于等于corePoolSize，都不会被释放，而如果大于corePoolSize，每个线程都有可能被释放，一视同仁。关于这几个参数更详细的解释可以见ThreadPoolExecutor类的源码注释：核心和最大池大小：ThreadPoolExecutor将根据核心线程池大小（参见getCorePoolSize）和最大线程池大小（参见getMaximumPoolSize）的设定自动调整线程池大小（参见getPoolSize）。当通过execute(Runnable)方法提交一个新任务时，如果运行的线程数少于核心线程池大小，将创建一个新线程来处理该请求，即使其他工作线程处于空闲状态。如果运行的线程数超过核心线程池大小但小于最大线程池大小，只有当队列已满时才会创建新线程。通过将核心线程池大小和最大线程池大小设置为相同的值，可以创建一个固定大小的线程池。通过将最大线程池大小设置为类似Integer.MAX_VALUE这样的近乎无限的值，可以允许线程池容纳任意数量的并发任务。通常情况下，核心线程池大小和最大线程池大小只在构造时设置一次，但也可以使用setCorePoolSize和setMaximumPoolSize动态地进行更改。创建新线程：新线程是使用ThreadFactory创建的。如果没有指定其他线程工厂，将使用Executors.defaultThreadFactory，它会创建位于相同ThreadGroup中，具有相同NORM_PRIORITY优先级和非守护状态的线程。通过提供不同的ThreadFactory，可以修改线程的名称、线程组、优先级、守护状态等。如果ThreadFactory在newThread中返回null时无法创建线程，执行器将继续运行，但可能无法执行任何任务。线程应该具有"modifyThread" RuntimePermission。如果使用线程池的工作线程或其他线程不具备此权限，服务可能会降级：配置更改可能无法及时生效，并且关闭池可能会保持在可以终止但未完成的状态。保活时间：如果线程池当前的线程数超过核心线程池大小，那么多余的线程将在空闲时间超过keepAliveTime时终止（参见getKeepAliveTime(TimeUnit)）。这提供了在池不活跃时减少资源消耗的方法。如果池后来变得更活跃，将会创建新线程。可以使用setKeepAliveTime(long, TimeUnit)方法动态地更改此参数。使用Long.MAX_VALUE TimeUnit.NANOSECONDS的值可以有效地禁用空闲线程在关闭之前终止。默认情况下，保持活动策略仅适用于运行线程数超过核心线程池大小的情况。但是，方法allowCoreThreadTimeOut(boolean)可以用于将此超时策略应用于核心线程，只要keepAliveTime值不为零即可。排队：可以使用任何BlockingQueue来传输和保存提交的任务。使用这个队列会影响池的大小： 如果运行的线程数少于核心线程池大小，Executor总是更喜欢添加新线程而不是排队。 如果运行的线程数等于或超过核心线程池大小，Executor总是更喜欢将请求排队而不是添加新线程。 如果无法将请求排队，将创建一个新线程，除非这会超过最大线程池大小，否则任务将被拒绝。 有三种常见的排队策略：直接移交。对于工作队列来说，一个很好的默认选择是SynchronousQueue，它会将任务直接移交给线程，而不会保存它们。如果没有线程立即可用于运行任务，尝试排队任务将失败，因此将创建一个新线程。这种策略避免了处理可能具有内部依赖关系的请求集时出现死锁。直接移交通常需要无界的maximumPoolSize来避免拒绝新提交的任务。这反过来可能导致线程无限增长，当命令平均到达速度比它们被处理得更快时，这种情况会出现。无界队列。使用无界队列（例如没有预定义容量的LinkedBlockingQueue）将导致新任务在所有corePoolSize线程都忙碌时等待在队列中。因此，最多只会创建corePoolSize个线程。（maximumPoolSize的值因此没有任何影响。）这种策略可能适用于每个任务完全独立于其他任务的情况，因此任务之间不会影响彼此的执行；例如，在一个网页服务器中。虽然这种排队方式可以平滑处理短暂的请求爆发，但是当命令的到达速度平均比它们能被处理的速度更快时，会导致无界的工作队列增长的可能性。有界队列。使用有界队列（例如，ArrayBlockingQueue）可以帮助在使用有限的maximumPoolSizes时防止资源耗尽，但调整和控制可能会更加困难。队列大小和最大池大小可以相互权衡：使用大队列和小池可以最小化CPU使用率、操作系统资源和上下文切换开销，但可能会导致人为地降低吞吐量。如果任务经常被阻塞（例如，如果它们受到I/O限制）。使用小队列通常需要更大的池大小，这使得CPU更忙碌，但可能会遇到不可接受的调度开销，从而降低吞吐量。拒绝策略：当 Executor 关闭时，以及 Executor 对最大线程和工作队列容量都使用有限的界限，并且已经饱和时，在方法execute(Runnable)中提交的新任务将被拒绝。无论哪种情况， execute方法都会调用其RejectedExecutionHandler的RejectedExecutionHandler.rejectedExecution(Runnable, ThreadPoolExecutor)方法。提供了四个预定义的处理程序策略：在默认的ThreadPoolExecutor.AbortPolicy中，处理程序在拒绝时抛出运行时RejectedExecutionException 。在ThreadPoolExecutor.CallerRunsPolicy中，调用execute本身的线程运行任务。这提供了一种简单的反馈控制机制，可以减慢提交新任务的速度。在ThreadPoolExecutor.DiscardPolicy中，无法执行的任务被简单地丢弃。在ThreadPoolExecutor.DiscardOldestPolicy中，如果 executor 没有关闭，则丢弃工作队列头部的任务，然后重试执行（可能再次失败，导致重复此操作。）可以自定义其他类型的RejectedExecutionHandler类。但这样做需要小心谨慎，尤其是当策略设计为仅在特定容量或排队策略下工作时。挂钩方法：此类提供protected的beforeExecute(Thread, Runnable)和afterExecute(Runnable, Throwable)方法，这些方法在执行每个任务之前和之后调用。这些可用于操纵执行环境；例如，重新初始化 ThreadLocals、收集统计信息或添加日志条目。此外，可以重写方法terminated以执行任何特殊处理，一旦 Executor 完全终止需要完成。如果钩子或回调方法抛出异常，内部工作线程可能会依次失败并突然终止。5.3 ThreadPoolExecutor源码ThreadPoolExecutor的主要属性如上图所示，其中Worker是ThreadPoolExecutor的内部类，一个Worker对象会绑定一个Thread对象，而ThreadPoolExecutor又会持有Worker对象集合 workers，这也就代表了ThreadPoolExecutor对象持有一个线程集合。另外poolSize用于记录当前线程池正存在的线程数量，runState是线程池的生命周期，线程池的生命周期可以分为运行（RUNNING）、关闭（SHUTDOWN）、、停止（STOP）、整理（TIDYING）和结束（TERMINATED）五个状态，他们分为等于-1 << Integer.SIZE-3、0 << Integer.SIZE-3、1 << Integer.SIZE-3、2 << Integer.SIZE-3、3 << Integer.SIZE-3，因此RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED现在就让我们分析下当调用ThreadPoolExecutor.submit(Runnable task)时发生了什么5.3.1 submit()public Future<?> submit(Runnable task) { if (task == null) throw new NullPointerException(); //将任务封装为RunnableFuture对象，其实是FutureTask对象，执行结果将放入这个对象里 RunnableFuture<Void> ftask = newTaskFor(task, null); //执行任务 execute(ftask); return ftask; }public void execute(Runnable command) { if (command == null) throw new NullPointerException(); //获得当前ctl值，ctl是一个原子属性，一个int变量分高低位代表线程池状态和当前线程池内的线程数量 int c = ctl.get(); //如果当前线程池数量小于corePoolSize 就创建一个线程来执行任务 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } //否则（代表当前线程数大于corePoolSize）如果任务入队列成功 if (isRunning(c) && workQueue.offer(command)) { //入队成功依然要检查线程数量，因为有可能入队期间线程池关闭了 //如果线程池关闭了就从队列里删除任务并拒绝 int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); //否则判断当前线程数量是否等于0，如果等于0则创建一个新线程允许任务 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //否则（代表线程数大于corePoolSize且入队列失败），则启用一个新的线程来执行任务 else if (!addWorker(command, false)) //如果这也失败了，代表队列也满了，线程也满了，那就启用拒绝策略 reject(command); }这里需要解释的是ctl属性，ctl是一个AtomicInteger，我们在这里可以简单将其理解为一个int类型，我们知道一个int有32位，其中ctl的高3位用于存储当前线程池的状态（由于线程池有5个状态，所以至少要3个bit才能表示），剩下的低29位用于记录当前线程池的线程数量。因此ThreadPoolExecutor最大支持的线程数量不是Integer.MAX，而是2^29-1，大概是5亿多个。其中ctl.get()调用的是AtomicInteger.get()获得当前原子类型对应的int值，runStateOf()是取int的高3位得到当先线程池的运行状态，isRunning()是判断运行状态是否等于RUNNING，而workerCountOf()是取int的低29位，获得当前线程池的线程数量。可以看到上面的逻辑与我们说的线程池的工作原理基本一致。//创建新的工作线程，如果创建成功返回true，否则返回false，其中core代表是否用core线程运行任务 private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { int c = ctl.get(); //取高3位得到运行状态 int rs = runStateOf(c); //a && !(b && c && !d) = a && (!b || !c || d) = a && !b || a && !c || a && d //因此这句话是说，如果当前线程池状态不是RUNNING且也不是SHUTDOWN直接返回false（其他状态下不执行任何任务，包括已有的） //或者当前线程池状态不是RUNNING且传入的任务不为空，直接返回false（非RUNNING状态不接收新任务，SHUTDOWN会执行完已有的） //或者当前线程池状态不是RUNNING且任务队列为空，直接返回false（没有待运行任务，且状态不是RUNNING就不必创建新的线程） if (rs >= SHUTDOWN && ! (rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty())) return false; for (;;) { //获得线程数量 int wc = workerCountOf(c); //如果大于2^29-1 //或者用核心线程的情况下但已有数量大于核心线程（核心线程都跑满了，无法再建一个为它服务） //或者不用工作线程，但线程总数大于maximumPoolSize了 //都返回false，不建新的线程 if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize)) return false; //走到这代表可以建线程，因为线程池状态既是ok的，线程数量也是允许的 //通过CAS操作将线程数量+1，如果成功跳出双层for循环 if (compareAndIncrementWorkerCount(c)) break retry; //否则如果线程池状态改了，继续双层for c = ctl.get(); // Re-read ctl if (runStateOf(c) != rs) continue retry; } } //走到这代表CAS操作已经成功，计数上的线程数量已经+1了 boolean workerStarted = false; boolean workerAdded = false; Worker w = null; try { //创建一个worker，worker会绑定一个线程 w = new Worker(firstTask); //拿到绑定的线程 final Thread t = w.thread; if (t != null) { final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //获得当前允许状态 int rs = runStateOf(ctl.get()); //在获得 mainLock 的锁定期间，再次检查线程池的运行状态，并确保线程池没有被关闭 if (rs < SHUTDOWN || (rs == SHUTDOWN && firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); //将新创建的worker加入到workers集合 workers.add(w); int s = workers.size(); //统计操作，如果当前线程数量大于历史最高就更新历史 if (s > largestPoolSize) largestPoolSize = s; workerAdded = true; } } finally { mainLock.unlock(); } //如果创建线程成功就启动线程 if (workerAdded) { t.start(); workerStarted = true; } } } finally { if (! workerStarted) addWorkerFailed(w); } return workerStarted; }可以看到这段代码主要是基于当前线程池状态和线程池内的线程数来决定是否创建一个新的线程。//创建一个worker对象，其实就是用ThreadFactory来创建一个线程 Worker(Runnable firstTask) { setState(-1); this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); }Worker对象是一个Runnable实现类，因此调用t.start();时会调到Worker.run()public void run() { runWorker(this); } final void runWorker(Worker w) { Thread wt = Thread.currentThread(); Runnable task = w.firstTask; w.firstTask = null; w.unlock(); // allow interrupts boolean completedAbruptly = true; try { while (task != null || (task = getTask()) != null) { w.lock(); //如果状态是STOP且STOP之后的状态，且线程设置了中断 if ((runStateAtLeast(ctl.get(), STOP) || (Thread.interrupted() && runStateAtLeast(ctl.get(), STOP))) && !wt.isInterrupted()) //就响应中断，表示线程中断，不再运行 wt.interrupt(); try { //运行任务，任务运行的前后加了beforeExecute和afterExecute钩子 beforeExecute(wt, task); Throwable thrown = null; try { task.run(); } catch (RuntimeException x) { thrown = x; throw x; } catch (Error x) { thrown = x; throw x; } catch (Throwable x) { thrown = x; throw new Error(x); } finally { afterExecute(task, thrown); } } finally { //释放锁和更新记录 task = null; w.completedTasks++; w.unlock(); } } completedAbruptly = false; } finally { processWorkerExit(w, completedAbruptly); } } 这里需要注意的是，如果task==null，会执行getTask()，private Runnable getTask() { boolean timedOut = false; // Did the last poll() time out? for (;;) { int c = ctl.get(); //拿到线程池的运行状态 int rs = runStateOf(c); //异常检查 if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) { decrementWorkerCount(); return null; } //拿到线程池的线程数量 int wc = workerCountOf(c); //allowCoreThreadTimeOut是一个属性，当核心线程池也可以配置keepAlive时为true，默认是fasle //timed用于标识这个线程是否需要keepalived boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; //异常检查 if ((wc > maximumPoolSize || (timed && timedOut)) && (wc > 1 || workQueue.isEmpty())) { if (compareAndDecrementWorkerCount(c)) return null; continue; } try { //从队列里获取任务，对于timed情况就是设置一个最大阻塞时间，而非timed不需要 //BlockingQueue我们之前已经讲过 Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); //返回任务 if (r != null) return r; timedOut = true; } catch (InterruptedException retry) { timedOut = false; } } }5.4 ScheduledThreadPoolExecutor源码相比于ThreadPoolExecutor，ScheduledThreadPoolExecutor提供了更丰富的定时功能，主要是提供了4个api：//延迟执行任务 public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit); //延迟执行任务 public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit); //在指定的initialDelay时间后执行任务，并具有周期性 //在上一个任务启动period后再次启动一个新的任务（无论上一个任务是否完成以及何时完成） public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit); //在指定的initialDelay时间后执行任务，并具有周期性 //在上一个任务执行完成period后再次启动一个新的任务（不完成就不会启动下一个） public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);在讲上面这些方法的源码前，我们先看下ScheduledThreadPoolExecutor的构造方法：public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory); } public ScheduledThreadPoolExecutor(int corePoolSize, RejectedExecutionHandler handler) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), handler); } public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory, RejectedExecutionHandler handler) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory, handler); }首先ScheduledThreadPoolExecutor继承自ThreadPoolExecutor，但在构造方法里我们不难发现ScheduledThreadPoolExecutor不支持指定BlockingQueue和maximumPoolSize以及keepAliveTime，这主要是因为ScheduledThreadPoolExecutor的阻塞队列是自己实现的DelayedWorkQueue，这个队列是无限长的队列，因此有了任务会直接入队列，自然就不需要maximumPoolSize，不需要maximumPoolSize代表线程都是核心线程，也即不需要keepAliveTime做保活判断。有了这些基础后，我们再来看一下上述这些方法的源码：5.4.1 schedule()public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) { if (command == null || unit == null) throw new NullPointerException(); //将任务和一些时间属性构造成RunnableScheduledFuture对象 RunnableScheduledFuture<?> t = decorateTask(command, new ScheduledFutureTask<Void>(command, null, triggerTime(delay, unit))); //延迟执行任务 delayedExecute(t); return t; }任务会首先被封装成ScheduledFutureTask类，这个类是ScheduledThreadPoolExecutor的内部类，它里面有几个重要的属性：private class ScheduledFutureTask<V> extends FutureTask<V> implements RunnableScheduledFuture<V> { //任务和任务的运行结果 private Callable<V> callable; private final long sequenceNumber; //任务开始运行的时间 private long time; //任务的运行周期 private final long period; }其中time就是当前系统时间+延迟运行的时间，如果当前任务是延迟任务而非周期任务时，period = 0，否则period等于周期时间。private void delayedExecute(RunnableScheduledFuture<?> task) { //如果线程池状态是非运行态，直接拒绝任务 if (isShutdown()) reject(task); else { //将任务入队，但这里入队的就是DelayedWorkQueue super.getQueue().add(task); //判断当前任务是否可以在线程池SHUTDOWN的时候继续运行，可以由参数设置 //如果不能且当前线程池状态是SHUTDOWN，则删除任务 if (isShutdown() && !canRunInCurrentRunState(task.isPeriodic()) && remove(task)) task.cancel(false); else //否则初始化一些线程 ensurePrestart(); } } //初始化一些线程，留着，不一定运行任务 void ensurePrestart() { int wc = workerCountOf(ctl.get()); if (wc < corePoolSize) addWorker(null, true); else if (wc == 0) addWorker(null, false); }DelayedWorkQueue是一个阻塞队列用于装submit的任务，这个队列本质是一个优先队列（二叉堆），排序的规则是任务的开始时间，也即队列里的元素是ScheduledFutureTask对象，它们根据属性的time排序，二叉堆的特性和实现我们在前面JUC源码详细分析之Part3——《常见的线程安全集合》已经讲过。我们在上面调用addWorker()的时候，传入的task == null，这样当线程启动的时候，会从workQueue里获取task，由于我们的workQueue实现是DelayedWorkQueue，因此当去take()的时候public RunnableScheduledFuture<?> take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { for (;;) { //获得第一个元素，因为二叉堆的特性决定了第一个元素最小，也即开始运行时间最小，最先开始运行 RunnableScheduledFuture<?> first = queue[0]; //这里采用Lock Condition，我们在锁那章中已经讲过，如果队列为null，就条件阻塞，会在有数据入队列的时候被唤醒 if (first == null) available.await(); else { //getDelay是用ScheduledFutureTask的time属性减去当前时间，也即还剩多少等待时间 long delay = first.getDelay(NANOSECONDS); //如果等待时间小于0，也即可以开始运行了 if (delay <= 0) //就直接将任务poll出来，与二叉堆源码类似，走sink操作 return finishPoll(first); first = null; // don't retain ref while waiting //这里会涉及一个leader属性，它是一个Thread类，类似于AQS里的exclusiveOwnerThread，指的都是当前获取锁的线程 //由于操作队列需要线程安全，因此会加锁 //每个线程去队列里poll数据的时候，都是会poll第一条数据，如果第一条数据还未到时间， //对于leader属性还未设置的，就先让自己抢占leader，然后挂起等待对应等待时间，条件变量挂起的时候会释放锁 //这样相当于第一个任务就已经属于这个线程了，其他线程再进来会看到leader是已被赋值的 != null //代表已经有线程对第一个任务负责了，因此直接await()挂起。 //那为何不先判断leader != null，而是先拿第一条数据判断是否能运行，如果能直接运行呢？ //这其实和非公平锁的原理差不多，leader线程从唤醒到执行可能还得一段时间，但如果此时插入了别的线程，就可以直接拿来执行 //此时被唤醒的leader线程会再次获取当前队列最新的第一个任务 if (leader != null) available.await(); else { //如果leader不为null，就设置leader为自己 Thread thisThread = Thread.currentThread(); leader = thisThread; try { //这个任务属于自己了，因此挂起相应的时间 available.awaitNanos(delay); } finally { //最后poll出任务后，将leader置空 if (leader == thisThread) leader = null; } } } } } finally { //leader poll出任务后 唤醒其他被阻塞的线程 if (leader == null && queue[0] != null) available.signal(); lock.unlock(); } }总的来讲，当我们执行schedule()的时候，任务会被封装为ScheduledFutureTask，然后进入DelayedWorkQueue，DelayedWorkQueue底层是一个二叉堆，会根据任务的开始运行时间排序，先运行的排在前面。然后当现在线程数量 workCount < corePoolSize的时候我们会创建一个task为null的线程，这个线程创建后启动，会从DelayedWorkQueue里拉取任务。拉取的时候如果任务还未到运行时间就通过Lock Condition 条件变量挂起等待，醒来后继续拉取。拉取到任务后就运行任务。Worker.runWorker()的运行是while true的形式，由于ScheduledThreadPoolExecutor的阻塞队列DelayedWorkQueue是无限长的，因此ScheduledThreadPoolExecutor中的线程都是core Pool，不存在销毁的情况（如果不设置core pool的keepalive的话），这时候每个线程运行完一个任务后会继续从队列里拉取下一个任务运行。整体相当于一个借助BlockingQueue的生产消费模型，我们提交任务的时候就是生产，任务入队列，线程池会从队列里poll任务来消费运行。5.4.2 scheduleAtFixedRate()与scheduleWithFixedDelay()scheduleAtFixedRate()和scheduleWithFixedDelay()与schedule()基本相同，唯一 不同的是在task的执行前判断是否是周期执行，如果是周期执行会设置下一次的运行时间，ScheduledFutureTask类继承自Runnable接口，调用Thread.start()的时候会先到达Worker.run()，然后到达ScheduledFutureTask.run()，下面是ScheduledFutureTask.run()的源码public void run() { //判断是否是周期执行，我们之前说过如果是非周期执行，period是0，否则是周期值 boolean periodic = isPeriodic(); //异常判断 if (!canRunInCurrentRunState(periodic)) cancel(false); //非周期执行的话，直接执行即可 else if (!periodic) ScheduledFutureTask.super.run(); //周期性执行的话就执行任务并设置任务的下一次运行时间然后将任务重新入队 else if (ScheduledFutureTask.super.runAndReset()) { setNextRunTime(); reExecutePeriodic(outerTask); } } //判断是否是周期运行 public boolean isPeriodic() { return period != 0; } //设置下一次的运行时间 private void setNextRunTime() { long p = period; if (p > 0) time += p; else time = triggerTime(-p); } long triggerTime(long delay) { return now() + ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay)); } //任务重新入队执行 void reExecutePeriodic(RunnableScheduledFuture<?> task) { //如果能继续运行就入队继续运行，否则取消任务 if (canRunInCurrentRunState(true)) { super.getQueue().add(task); if (!canRunInCurrentRunState(true) && remove(task)) task.cancel(false); else ensurePrestart(); } }这里唯一需要说的是scheduleAtFixedRate()与scheduleWithFixedDelay()，这两个方法在设置周期的时候，为区分两种情况scheduleAtFixedRate()的任务ScheduledFutureTask里的period就是正常的周期值，但scheduleWithFixedDelay()的任务ScheduledFutureTask里的period周期值取了反：public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { if (command == null || unit == null) throw new NullPointerException(); if (period <= 0) throw new IllegalArgumentException(); ScheduledFutureTask<Void> sft = new ScheduledFutureTask<Void>(command, null, triggerTime(initialDelay, unit), unit.toNanos(period)); RunnableScheduledFuture<Void> t = decorateTask(command, sft); sft.outerTask = t; delayedExecute(t); return t; } public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, //正常的周期 long delay, TimeUnit unit) { if (command == null || unit == null) throw new NullPointerException(); if (delay <= 0) throw new IllegalArgumentException(); ScheduledFutureTask<Void> sft = new ScheduledFutureTask<Void>(command, null, triggerTime(initialDelay, unit), //可以看到这里取了反 unit.toNanos(-delay)); RunnableScheduledFuture<Void> t = decorateTask(command, sft); sft.outerTask = t; delayedExecute(t); return t; }因此ScheduledFutureTask.period小于0的就是scheduleWithFixedDelay，等于0的是非周期任务，大于0的是scheduleAtFixedRate。这一设置会在上面的setNextRunTime()中得以体现：private void setNextRunTime() { long p = period; if (p > 0) time += p; else time = triggerTime(-p); } long triggerTime(long delay) { return now() + ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay)); }可以看到，当周期大于0的时候，下一次运行时间是上一次开始运行的时间+周期，但周期小于0的，是当前时间+周期，这也就对应了scheduleAtFixedRate和scheduleWithFixedDelay两种情况。6. 结尾的一些话计算机行业的发展似乎总是日新月异，新的技术和名词层出不穷，越来越多的人评价这个行业内卷，需要持续的学习。金庸先生的武侠小说中有一门武功叫九阳真经，这是门心法武功，学会九阳真经后再学所有天下武功都是触类旁通，一学即会。天下武功，虽千变万化但最终的心法终究是一样的，掌握住这个最核心的心法，便掌握了天下的所有武功。计算机的学习也是如此，而计算机界的九阳真经就是我们大学学的那些《操作系统》、《数据结构》、《算法》、《计算机网络》等知识。笔者在写这篇文档的时候是2022年，距离Doug Lea完成JUC已经17年之久，而笔者重点参考的博客《深入浅出 Java Concurrency》也距今有12年之久。那么多年，新的技术层出不穷，但JUC依然是现在Java面试的必考题。且不论面试题的八股与否，JUC源码的学习确实让笔者成长了不少，而JUC也是笔者首次揭开源码世界的大门。笔者也坚持认为，像JUC这样的知识，它就是Java界的九阳真经（其实JUC的知识已经远超JAVA范畴），多读这些“核心功法”的源码，提升自己的开发内功，才能触类旁通，学什么都很快。参考文档《深入浅出 Java Concurrency》目录 - xylz,imxylz - BlogJava从ReentrantLock的实现看AQS的原理及应用 - 美团技术团队 (meituan.com)不可不说的Java“锁”事 - 美团技术团队 (meituan.com)Java 读写锁 ReentrantReadWriteLock 源码分析_Javadoop《操作系统导论》 多读少写的场景 如何提高性能_awesome_go的博客-CSDN博客_读多写少阅读 JDK 源码：线程池 ThreadPoolExecutor_Sumkor的博客-CSDN博客
JUC源码详细分析之Part3——《常见的线程安全集合》
JUC源码详细分析之Part3——《常见的线程安全集合》本系列笔记笔者将会详细的讲解JUC中核心类的源码，其中基于的JDK版本为1.8.0_291。由于JUC内容很多，因此笔者将笔记拆为四部分：JUC源码详细分析之Part1——《CAS和原子类型》JUC源码详细分析之Part2——《锁》JUC源码详细分析之Part3——《常见的线程安全集合》JUC源码详细分析之Part4——《线程池》另外，本系列笔记部分内容参考自《深入浅出 Java Concurrency》4. 常见的线程安全集合本章主要讲JUC下部分线程安全的数据结构，我们在日常开发中会经常使用他们，但是关于他们的实现原理与源码很多同学可能没了解过，现在就带大家了解JUC下常用数据结构的源码。4.1 ConcurrentHashMap我们之前利用读写锁实现了一个线程安全的HashMap，但是这个Map的性能并不高，因为首先他只支持同一时间一个线程的写入，另外读取的时候也需要加锁读取，在写的时候不允许读取，我们希望一个效率更高的map来替换之前读写锁版本的map，至少他需要改进两个功能：可以支持一定量的并发修改读取的时候不需要加锁在理解ConcurrentHashMap前，我们默认读者对HashMap的实现是有一定了解的，有HashMap源码的经验，包括JDK1.7和JDK1.8版本不同的实现方式。同样，ConcurrentHashMap的实现在JDK1.7和JDK1.8版本中也有所不同，我们先来说JDK1.7版本中ConcurrentHashMap的实现。4.1.1 JDK1.7 ConcurrentHashMap在JDK1.7中，ConcurrentHashMap的实现采用了锁分段的思想（JDK1.8也是，不过锁的颗粒度不同）。首先我们知道JDK1.7中HashMap是一个数组加链表的结构，假设我们将数组进行拆分，拆成n份，那么其实每一份是一个更小的数组，或者说每一份是一个更小的HashMap。我们将每一份小的HashMap称为段(Segment)。那么如果我们在操作的时候，先定位到使用哪个段，然后再在段内进行线程安全的增删改查。也即，每个段都是彼此并行的，段与段没有交集，这样最多可支持并发为n的写入/删除操作（n为段的数量）。如：当前Map数组长度2n，将Map分为n个段，那么每个段的数组长度就是2，每个段都可以认为是个小的HashMap，段与段之间并行，段内的元素操作都是线程安全的。JDK1.7中ConcurrentHashMap的实现，其结构如下：首先ConcurrentHashMap内包含一个segments，即段数组，它包含16个段，每个段都是一个类HashMap结构，这个结构（Segment）持有一个独占锁，保证在对段进行修改操作时是线程安全的。另外哈希桶的实现（HashEntry）的实现是链表的方式，即相同hash值的节点通过链表串起来（与JDK1.7相同）。在这里我们引入了段，为了提高map修改时的并发能力，但同样也带来了一个问题：当我们要操作一个元素时，如何定位这个元素在哪个段上，即原来我们只需要定位哈希桶，然后遍历链表节点，现在需要变为段->哈希桶->节点。JDK1.7中的实现依然是通过hash，即先通过hash定位到段，再通过二次hash定位到哈希桶(hashEntry)，然后再通过遍历链表得到具体的节点。了解了上面这些实现，我们可以从另一个角度看一下ConcurrentHashMap。首先ConcurrentHashMap内部有一个HashMap，这个HashMap的key是段号，value值是Segment。同时Segment也是一个HashMap，因此我们可以认为ConcurrentHashMap是一个value为HashMap的HashMap，这样也就不难理解进行元素操作时需要二次hash了。我们先来看下JDK1.7中ConcurrentHashMap是如何通过key获得段的：首先会将key值的hashCode(每一个Java Object都有hashCode)通过hash()进行二次编码（哈希算法打散均匀），然后将得到打散后的hash值通过segmentFor()函数，就可以定位到key对应的segments数组对应的数组下角标，返回数组下角标出处的Segment即可。熟悉HashMap的同学不难发现这与HashMap获得哈希桶的方式一模一样。//hash算法，打散key的hashcode private static int hash(int h) { // Spread bits to regularize both segment and index locations, // using variant of single-word Wang/Jenkins hash. h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); h += (h << 3); h ^= (h >>> 6); h += (h << 2) + (h << 14); return h ^ (h >>> 16); } //通过hash得到数组的index，返回Segment final Segment<K,V> segmentFor(int hash) { return segments[(hash >>> segmentShift) & segmentMask]; }与HashMap中的哈希桶数组不同，segments不支持resize操作，也即segments的大小一旦初始化无法修改。了解了这个后我们再来看下JDK1.7下的ConcurrentHashMap的增删改查：get我们之前已经讲了如何获得Segment，这里的get操作是获得Segment后的get，也即这里的get()是Segment对象的函数，而非CouncrrentHashMap的函数，下面讲的put()、remove()同理，不再赘述。V get(Object key, int hash) { //count变量是指段内的元素个数，如果元素个数为0，代表没有可读的直接返回 if (count != 0) { //getFirst函数就是根据当前hash值得到HashEntry，即哈希桶的头结点 HashEntry<K,V> e = getFirst(hash); //当头节点不为空就开始遍历链表 while (e != null) { if (e.hash == hash && key.equals(e.key)) { //找到这个节点，如果节点value不为空就返回 V v = e.value; if (v != null) return v; //否则加锁再读一遍 return readValueUnderLock(e); } e = e.next; } } return null; } //getFirst操作 得到当前hash值所在的HashEntry头结点 HashEntry<K,V> getFirst(int hash) { HashEntry<K,V>[] tab = table; return tab[hash & (tab.length - 1)]; } //如果读取的值为null，加锁再读一遍 V readValueUnderLock(HashEntry<K,V> e) { lock(); try { return e.value; } finally { unlock(); } }这里唯一比较特殊的是readValueUnderLock(e)处的代码，在读到value为空时，还要加锁再读一遍，这是为什么呢？这与value的修改对当前线程还不可见有关，我们会在下面的put操作时详细解释。putV put(K key, int hash, V value, boolean onlyIfAbsent) { //由于修改段结构，因此加锁 lock(); try { int c = count; //如果当前段内元素大于阈值，进行rehash操作 if (c++ > threshold) // ensure capacity rehash(); //得到当前key的hash值对应的HashEntry HashEntry<K,V>[] tab = table; int index = hash & (tab.length - 1); HashEntry<K,V> first = tab[index]; HashEntry<K,V> e = first; //如果e!=null，代表当前有与当前hash相同的节点，因此遍历这个链表，直到找到一个与当前节点相同的节点或遍历到最后一个空节点(此时e==null) while (e != null && (e.hash != hash || !key.equals(e.key))) e = e.next; V oldValue; //e!=null 代表当前put的这个值是已经存在的 if (e != null) { oldValue = e.value; //如果onlyIfAbsent = false 就更新这个值 否则什么也不干 //这里那么做的原因是为了实现putIfAbsent函数 if (!onlyIfAbsent) e.value = value; } //否则 即e==null 代表当前段内没有与当前要插入的节点相同的元素 else { oldValue = null; //修改modCount的值 ++modCount; //将当前节点构造为HashEntry，并设为这个链表的头结点，加入到哈希数组中。 tab[index] = new HashEntry<K,V>(key, hash, first, value); //回写count的值(或者说修改count的值) count = c; // write-volatile } return oldValue; } finally { unlock(); } }可以看到put整个操作都是加锁的，因此针对一个段的修改是线程安全的，这里我们就需要讲下在get()操作时留下的那个问题：在读到value为空时，还要加锁再读一遍，这是为什么呢？这与tab[index] = new HashEntry<K,V>(key, hash, first, value);这段代码有关。这段代码做的是将当前元素构造为HashEntry并设为整个链表的头结点，然后将这个新的链表加入到数组中。这里会存在一个重排序的问题：理论上，我们是先new了这个HashEntry，然后将数据赋给HashEntry对象，再将这个HashEntry赋给tab[index]，即new HashEntry() -> 给 HashEntry赋值 ->设置进tab[index]，但编译器有可能会对这个顺序进行重排，重排后顺序为：new HashEntry() ->设置进tab[index]-> 给 HashEntry赋值 。这样就会存在一个问题：如果我们将HashEntry设置进tab[index]后，设置了HashEntry的key但还没设置value时，此时进来了查询语句，想象会发生什么？查询会查到这个key值对应的value是null，因为我们还没来得及赋值。因此在get操作时，如果读到的value是null,执行了readValueUnderLock()函数，这个函数会加锁，如果此时我们正在写入，那么这个函数会等到写入完后再重新读取一遍value。removeV remove(Object key, int hash, Object value) { //首先加锁 lock(); try { //修改count的值 int c = count - 1; //寻找当前key值对应的HashEntry节点 HashEntry<K,V>[] tab = table; int index = hash & (tab.length - 1); HashEntry<K,V> first = tab[index]; HashEntry<K,V> e = first; //遍历这个链表 一直找到我们要删除的节点或者找到一个空节点为止 while (e != null && (e.hash != hash || !key.equals(e.key))) e = e.next; V oldValue = null; //不等于null 代表找到了 if (e != null) { V v = e.value; //ConcurrentHashMap的remove有一个参数为value，如果这个value传null代表不关心value，找到key值相同的节点删除即可 //但如果value!=null 代表调用者不仅要求key相同，还需要这个节点的key对应的value也是自己想要的value，只有这种情况下才会执行删除操作 if (value == null || value.equals(v)) { oldValue = v; ++modCount; //从原来链表中删除当前节点并且构造成新的链表赋值进tab数组。 HashEntry<K,V> newFirst = e.next; for (HashEntry<K,V> p = first; p != e; p = p.next) newFirst = new HashEntry<K,V>(p.key, p.hash, newFirst, p.value); tab[index] = newFirst; count = c; // write-volatile } } return oldValue; } finally { unlock(); } }删除操作需要提及的是if (value == null || value.equals(v)) { oldValue = v; ++modCount; //从原来链表中删除当前节点并且构造成新的链表赋值进tab数组。 HashEntry<K,V> newFirst = e.next; for (HashEntry<K,V> p = first; p != e; p = p.next) newFirst = new HashEntry<K,V>(p.key, p.hash, newFirst, p.value); tab[index] = newFirst; count = c; // write-volatile }这段代码，对于为什么if我们在注释中已经解释了，这里需要解释的是：如何从链表中删除一个节点？ConcurrentHashMap会先将当前节点的继任节点取出作为新链表的头结点，假设这样形成的链表我们命名为C，然后从原来的头结点开始遍历，每遍历一个节点都将这个节点设为链表C的头结点，直到遍历到当前节点为止，这样得到新的链表赋给原来的tab[index]即可。流程大概如下图：假设我们要从链表B1->B2->B3->B4->B5 中删除删除B3，那么先将B4设为新链表的头结点，即B4->B5，然后从原来的头结点开始遍历，即B1，每遍历一个节点都将这个节点设为新链表的头结点，此时新链表就是B1->B4->B5。 然后是 B2->B1->B4->B5，此时新的链表形成，将这个新的链表赋给table[index]。这里大家可能会有几个问题：为什么要这么做？在其他链表结构中，如果删除链表中的节点，很可能是B3->pre->next = B3->next即可。但ConcurrentHashMap内的元素节点没有pre指针，另外他们的next指针都是final的，这样，链表结构如果变动只能重新生成节点。如果我们在删除节点的时候发生了读事件，读到的会是什么情况？如果新的链表还没设置进table，那么读取时会用旧的链表来读取，自然读到的是旧数据。也即旧的链表在新的链表生成过程中是依然存在的，这样做可以保证在删除时读操作依旧能读到数据，只不过可能不是最新的。旧的链表会在我们将新链表设置进table[index]后失去引用，然后被GC回收。这样我们就完成了ConcurrentHashMap的增删改查操作，这里还有一些需要注意的细节：当我们对一个段进行操作时，对段的修改操作由于加了锁因此保证同时只能有一个线程在修改段，但读取是没有加锁的，因此可以有多个线程同时读取段。为了让修改线程修改的内容能被读取线程看到，一些字段就需要设置为volatile，如Segment中的count，table属性，HashEntry中的value属性。在对volatile字段进行更新时，代码基本上都是先建立一个临时变量指向volatile变量，然后读写操作就在这个临时变量中进行，最后再将这个临时变量赋值给volatile变量。如：int c = count - 1; //寻找当前key值对应的HashEntry节点 HashEntry<K,V>[] tab = table; int index = hash & (tab.length - 1); HashEntry<K,V> first = tab[index];这样做的原因是多次读取volatile类型的开销要比非volatile开销要大，而且编译器也无法优化，因此建立临时变量，这样多次读写tab的效率要比volatile类型的table要高，JVM也能够对此进行优化。从这里我们也看出JDK源码编写者们对代码编写的严苛程度。4.1.2 JDK1.8 ConcurrentHashMapJDK1.8中也采用分段锁的思想，不过JDK1.8取消了段的概念，加锁的颗粒度设为了每一个哈希桶(也可以理解为HashMap数组中的每一个元素都是独立的加锁)，另外JDK1.8中采用synchronized和CAS乐观锁来代替JDK1.7中的独占锁保证线程安全性。当然与HashMap一致的是JDK1.8中ConcurrentHashMap也引入红黑树。JDK1.8中ConcurrentHashMap的实现极其复杂，其源码有6000多行，内部类有50多个，本篇笔记也只是对JDK1.8的ConcurrentHashMap浅析，主要讲一下常用API的源码：put//调用put操作插入KV public V put(K key, V value) { return putVal(key, value, false); }//onlyIfAbsent如果为true则代表只插入不替换，换句话说，如果K存在就不做操作 //如果onlyIfAbsent为false则代表相同的K会替换value final V putVal(K key, V value, boolean onlyIfAbsent) { //ConcurrentHashMap不同于HashMap，不允许key为null if (key == null || value == null) throw new NullPointerException(); //得到key的hash int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; //如果table==null或者tab.length==0代表数组为空，此时就需要初始化 if (tab == null || (n = tab.length) == 0) //数组初始化，后面我们会讲 //数组初始化完成后，数组就不再为空，下一次for循环也就不会再进入当前分支 tab = initTable(); //tabAt操作会获得当前K所在的数组的HashEntry(即这里的Node)，并将它赋给f变量 //如果f==null 代表当前K对应的数组index处的元素不存在，因此就需要先初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //new Node即初始化当前节点，然后通过CAS操作将当前Node设置进table //如果CAS成功则当前put操作基本完成，直接到倒数第二行即可 //如果CAS不成功，代表当前有并发操作，下一次的for循环，会走进别的分支 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } //这里的MOVED是-1，如果进入这个分支代表当前正在扩容，那么这个插入节点也会帮着转移数据，这个我们后面会讲 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { //走到这个分支代表： //1. 数组不为空 //2. 当前tab[index]处元素不为空 //3. 当前Map不是正在扩容 //我们之前说过JDK1.8取消了段的概念，加锁的颗粒度设为了每一个哈希桶 //通过前面代码我们知道f就是哈希桶头节点，因此此处就是通过synchronized锁住头节点保证对哈希桶的加锁 V oldVal = null; synchronized (f) { //加锁后再确认一遍f是当前头节点，因为前面的操作均为加锁，有可能f被删了 //如果f不再是我们的头节点，那么会再次进入下一次的for循环重新获得新的哈希桶头节点 if (tabAt(tab, i) == f) { //走到这里代表加锁后f就是我们的哈希桶头节点 //头节点hash>0说明当前哈希桶的实现是链表 //了解HashMap的知道，JDK1.8中哈希桶有链表和红黑树两种实现且会相互转换 if (fh >= 0) { binCount = 1; //开始遍历链表 for (Node<K,V> e = f;; ++binCount) { K ek; //这个if是说链表中存在节点k与我们要插入的k相等 if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; //如果允许替换就替换了 if (!onlyIfAbsent) e.val = value; //结束for循环，完成插入 break; } //否则走到这代表当前遍历的k不等于我们要插入的k Node<K,V> pred = e; //如果遍历到链表的最后一个节点还没有相等的k， //说明这是个新的k，就将它封装为Node加入到链表的末尾 if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); //结束for循环 完成插入 break; } } } //这里代表fh < 0 代表当前哈希桶是红黑树构成 //这是因为红黑树在构造的时候将红黑树的根节点的hash设置为了-2 //每棵红黑树的根节点都是假节点，这个节点的hash是常量-2 else if (f instanceof TreeBin) { Node<K,V> p; //调用红黑树的插入 binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } //binCount这个变量可以理解为是一个flag，他在初始化的时候是0 //在插入的时候如果是向链表尾部追加节点，此时binCount是1-n（跟链表遍历相关） //在插入的时候如果是向红黑树中追加节点，此时binCount是2 //因此binCount != 0且binCount >=8 则代表一定是向链表尾追加了节点 //此时就会触发红黑树与链表的转换，会将链表转为红黑树 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) //这个方法和 HashMap 中稍微有一点点不同，那就是它不是一定会进行红黑树转换 // 如果当前数组的长度小于 64，那么会选择进行数组扩容，而不是转换为红黑树 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } //更新记录并判断是否需要扩容,如果需要就扩容 addCount(1L, binCount); return null; }上述便是put流程的主要代码，我们可以做个流程总结：如果key或value为null，抛出异常，否则执行步骤2如果数组为空，则初始化数组，然后执行步骤2，否则执行步骤3如果key所对应的Node不等于空，执行步骤4。否则创建一个Node并插入到哈希数组中，如果插入成功执行步骤7，如果插入失败则执行步骤2。如果哈希正在扩容则帮助扩容迁移，然后执行步骤2，否则执行步骤5如果Node不是头节点了，执行步骤2。否则如果Node是链表将节点插入到链表尾，如果Node是红黑树则将节点插入到红黑树中，然后执行步骤6如果刚才插入到的是链表尾部且链表长度大于等于8则执行扩容或链表转红黑树操作，然后执行步骤7更新记录并判断是否需要扩容,如果需要就扩容上面有几点比较重要的我们需要细说：数组初始化，扩容，帮助数据迁移数组初始化//数组初始化操作是需要线程安全的，因为可能会有多个线程想要同时执行数组初始化 //那么怎么保证这种线程安全呢？加锁是最简单的，下面函数的实现虽然没有使用锁但使用了锁的核心思想 //在前面Lock的学习中，我们知道锁就是一个标志位，现在这个标志位就是sizeCtl，将sizeCtl设置为-1的线程代表获得了锁 //有了这个基础我们看下数组初始化的实现 private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; //由于是CAS操作，所以需要循环保证重试 while ((tab = table) == null || tab.length == 0) { //如果此时sizeCtl已经小于0，代表已经有线程持有锁了，当前线程就需要yield，让抢到锁的线程去初始化。 if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin //如果sizeCtl还不是-1，代表锁还未被抢到，此时当前线程尝试设置SIZECTL为-1来获得锁 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { //获得锁成功后开始执行初始化操作 if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; //这里相当于sc = n- n/4，也即sc = 0.75n，其中0.75是负载因子 sc = n - (n >>> 2); } } finally { //初始化完成恢复sizeCtl sizeCtl = sc; } break; } } return tab; }链表转红黑树时会发生扩容的可能//将链表转为红黑树 private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { //如果数组长度小于64会走扩容流程，不转红黑树 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) //扩容，数组大小扩容1倍 tryPresize(n << 1); //获得头节点 else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { //加锁头节点，避免线程安全 synchronized (b) { //再次判断b是否是头节点 if (tabAt(tab, index) == b) { //遍历链表转红黑树 TreeNode<K,V> hd = null, tl = null; for (Node<K,V> e = b; e != null; e = e.next) { TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null); if ((p.prev = tl) == null) hd = p; else tl.next = p; tl = p; } //将新的红黑树数据结构设置进数组 setTabAt(tab, index, new TreeBin<K,V>(hd)); } } } } }//resize操作 private final void tryPresize(int size) { int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1); int sc; while ((sc = sizeCtl) >= 0) { Node<K,V>[] tab = table; int n; if (tab == null || (n = tab.length) == 0) { n = (sc > c) ? sc : c; if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if (table == tab) { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc; } } } else if (c <= sc || n >= MAXIMUM_CAPACITY) break; else if (tab == table) { int rs = resizeStamp(n); if (sc < 0) { Node<K,V>[] nt; if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); } } }4.2 ConcurrentLinkedQueueConcurrentLinkedQueue是一个链表实现的线程安全的队列，队列内元素的顺序就是入队列的顺序，队列头是队列内时间最久的，而队列尾就是队列中时间最短的。通过我们前面对锁的学习，可以设想下实现一个线程安全的队列有几种方法：使用synchronized来同步队列使用Lock，用读写锁或独占锁。使用乐观锁CAS和不断循环的方式。ConcurrentLinkedQueue是通过第三种方案CAS来实现线程安全的。ConcurrentLinkedQueue内部的属性比较简单，主要的属性只有两个，节点头和节点尾private transient volatile Node<E> head; private transient volatile Node<E> tail;其中Node类结构更加简单，只有value和next指针，这里可以看到ConcurrentLinkedQueue是一个单向链表。private static class Node<E> { volatile E item; volatile Node<E> next; }正常的增加和删除节点只能操作头结点和尾节点，如果想操作中间节点必须要遍历这个队列。这里所有的属性都是volatile类型，因为ConcurrentLinkedQueue是非阻塞的，需要保证写入可以被后续的读取看到，同时避免重排序。队列的头节点是无意义节点也即伪节点，当类初始化的时候，会初始化一个无意义节点赋值给head和tail指针，但当插入一条数据后会修改tail指针的值，将tai指针变得有意义。也即head指针一直都是无意义的，当队列内有数据时tail指针就是真实的最后一个数据，但当队列内无数据,tail=head=伪节点。public ConcurrentLinkedQueue() { head = tail = new Node<E>(null); }队列的插入：todo：看不懂里面的逻辑//向队列尾插入一条数据 public boolean offer(E e) { //校验数据非空 checkNotNull(e); //将数据封装为node节点 final Node<E> newNode = new Node<E>(e); //将tail赋给p //这里的逻辑会有些复杂，首先我们需要明确tail指针指向的永远都是尾节点那个元素，这时候将t和p都指向当前tail指针指向的那个元素 //但如果此时有别的线程修改了尾节点，那么自然tail指针指向的节点也会被修改，那t和p指向的就不再是新的尾节点了，而还是之前那个旧的尾节点 //此时p==t!=tail了。 这有点类似于tail->Obj1,t = tail,此时t->obj1，而p = t ,p->obj1, //此时有个线程修改了tail的指向 tail->obj2，但p和t都未被修改，还指向之前的obj1 for (Node<E> t = tail, p = t;;) { //得到尾节点的next指针，赋值给q Node<E> q = p.next; //只有next为空的时候才操作 if (q == null) { //将当前节点设为p的next指针，当且仅当p.next==null才操作 if (p.casNext(null, newNode)) { if (p != t) casTail(t, newNode); return true; } } else if (p == q) p = (t != (t = tail)) ? t : head; else p = (p != t && t != (t = tail)) ? t : q; } }出队列：public E poll() { restartFromHead: for (;;) { for (Node<E> h = head, p = h, q;;) { E item = p.item; if (item != null && p.casItem(item, null)) { if (p != h) updateHead(h, ((q = p.next) != null) ? q : p); return item; } else if ((q = p.next) == null) { updateHead(h, p); return null; } else if (p == q) continue restartFromHead; else p = q; } } }遍历数组的大小public int size() { int count = 0; for (Node<E> p = first(); p != null; p = p.getNext()) { if (p.getItem() != null) { // Collections.size() spec says to max out if (++count == Integer.MAX_VALUE) break; } } return count; }4.3 阻塞队列 BlockingQueue阻塞队列就是会对元素的入队和出队操作在不满足条件时进行阻塞。比如如果队列在无元素时执行出队列操作，那么会一直阻塞到队列内有可出队列的元素为止。阻塞队列有两个方法 put和take即阻塞的入队和出队操作。阻塞队列的使用场景非常普遍，最常见的就是生产消费模型，生产者生产信息往队列中put，当队列满了就需要阻塞，消费者消费数据就是从队列中take，如果队列内无可取的元素就阻塞直到有元素。在JUC中对于阻塞队列有几种不同的实现，下面我们一一看下。4.3.1 LinkedBlockingQueueLinkedBlockingQueue内部属性如下：//队列的容量 当队列满了后再put就会阻塞 private final int capacity; //队列当前实际元素的数量 private final AtomicInteger count = new AtomicInteger(); //头节点 transient Node<E> head; //尾节点 private transient Node<E> last; //取锁 private final ReentrantLock takeLock = new ReentrantLock(); //notEmpty信号量 private final Condition notEmpty = takeLock.newCondition(); //写锁 private final ReentrantLock putLock = new ReentrantLock(); //notFull信号量 private final Condition notFull = putLock.newCondition();Node节点的信息如下static class Node<E> { E item; Node<E> next; Node(E x) { item = x; } }LinkedBlockingQueue内部维持了一个线程安全的count，这不像ConcurrentLinkedQueue需要遍历整个队列得到size。另外LinkedBlockingQueue内部维护了两个锁，一个是take时的锁一个是put时的锁。在ConcurrentLinkedQueue中我们知道，其实出入队列基本是相互分离不干扰的，出队列就是操作head，而入队列是操作tail，因此我们可以认为take和put操作是互不干扰的，也即可以并行执行的。通过两个锁保证入队和出队不互相影响，提高了吞吐率。入队列：public void put(E e) throws InterruptedException { if (e == null) throw new NullPointerException(); int c = -1; //将当前元素封装为节点 Node<E> node = new Node<E>(e); final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; //加锁 直到线程被中断 putLock.lockInterruptibly(); try { //如果满了就挂起 通过条件变量notFull挂起 //注意这里用了while没用if 这一原因我们之前说过 while (count.get() == capacity) { notFull.await(); } //走到这里代表当前队列不满，执行入队列操作 enqueue(node); //先将count的值赋给c，然后再将count+1，这里记住是先赋给c，再加1 c = count.getAndIncrement(); //如果没满发布notFull 唤醒因为notFull阻塞的线程 if (c + 1 < capacity) notFull.signal(); } finally { //释放锁 putLock.unlock(); } //因为上面是先赋给c再加1，所以c==0代表之前队列是空的，但现在put进来了元素，所以需要唤醒因为队列为空而挂起的消费线程 if (c == 0) signalNotEmpty(); } //入队列函数 比较简单不再解释 private void enqueue(Node<E> node) { last = last.next = node; } //发布非空，简单的lock与condition配合的场景 private void signalNotEmpty() { final ReentrantLock takeLock = this.takeLock; takeLock.lock(); try { notEmpty.signal(); } finally { takeLock.unlock(); } }这里需要考虑的一点是为什么if (c + 1 < capacity) notFull.signal();有这样一段代码。为什么我们在入队的时候还需要唤醒其他入队挂起的线程，正常来说入队的线程不是应该由出队线程唤醒吗？这是因为出队线程出队成功后，判断如果之前队列是满的，确实会执行唤醒操作，但执行的是signal()而非signalAll()（一会我们会看到）signal()存在一个问题是可能不一定成功唤醒这个线程，如果没有成功唤醒，那么这些线程还在挂起，此时入队的线程如果发现可以继续入队，那么他也会尝试唤醒，这样避免了因为出队线程signal()不成功而入队线程挂起过久的情况。我们来看下signal()为什么可能唤醒不成功？假设我们现在有两个线程A、B正在被条件变量挂起，但他们的条件不同，如：A: while(x >10){ condition.await(); } B: while(x > 5){ condition.await(); }此时线程C执行signal()操作，JVM会随机选择一个挂起的线程唤醒，假设此时选择了B线程唤醒，但因为x是7，所以B在唤醒后会继续await()挂起，但此时线程A因为没接收到通知，还在挂起等待唤醒，即使A应该醒来了。还一种情况是，我们唤醒了生产者B线程，但此时B抛出了异常，那么按理挂起的A可以接着生产，但因为没有被通知，还是会被挂起。因此这里多一个signal()操作可以避免线程唤醒失败导致其他挂起线程等待过久的情况。另外为什么选择用signal()而不用signalAll()？如果使用signalAll()全都通知到，似乎就不存在上面说的问题了。假设此时队列是满的，因为队列满挂起了10000个生产者线程，然后此时消费者消费了一个元素，执行signalAll()操作，想象一下会发生什么？10000个线程都会醒来，为了这一个可生产的位置不断的尝试抢占锁，但最多只有一个线程能获得锁，其他线程又会被再次挂起，这样就会带来大量的上下文切换消耗，这一现象有个比较专业的名词叫惊群出队列：public E take() throws InterruptedException { E x; int c = -1; final AtomicInteger count = this.count; final ReentrantLock takeLock = this.takeLock; //加锁 takeLock.lockInterruptibly(); try { //如果队列内没数据 挂起 while (count.get() == 0) { notEmpty.await(); } //被唤醒后进行出队列操作 x = dequeue(); //同put操作，是先赋值再做减操作 c = count.getAndDecrement(); if (c > 1) //如果队列内依然有数据 进行非空通知 notEmpty.signal(); } finally { takeLock.unlock(); } //如果之前队列是满的，现在我们take走了一个元素，因此需要唤醒因为队列满了而挂起的生产线程 if (c == capacity) signalNotFull(); return x; } //出队列 因为队列在初始化的时候有一个假节点 也即队列的头结点永远是假节点 //当出队列的时候，会先将当前队列头节点释放掉 然后取出真实头结点 将真实头结点的值返回 然后再将当前真实头结点变为新的假节点 private E dequeue() { Node<E> h = head; Node<E> first = h.next; //释放假的头结点 h.next = h; // help GC head = first; //将真实数据返回 E x = first.item; //将刚才的真实头结点变为新的假节点 first.item = null; return x; }这里比较有意思的一点是在删除的时候，将真实的头结点变为新的假头结点，而不像更简单的：fakeNode->next = fakeNode->next->next这种操作，这种操作看起来更简单，一直保留这个假节点，删除的时候将真实节点从中删掉就好了，这样更方便不是吗？想象这样一种情况，当前队列内只有一个元素，那么我们队列的结构就会如下：fakeNode->next = node; last = node; node->next = null;如果先执行take再执行put操作时，take操作按照fakeNode->next = fakeNode->next->next，那么就变成//take fakeNode->next = fakeNode->next->next; //put last->next = newNode; last = newNode;take操作时，因为队列内只有一个元素，那么take之后fakeFirst->next = null，此时队列情况为：当再执行put操作时： last.next = node;。但此时last指针的指向是NULL，last.next会报NullPointerException。因此，这里删除选择的是替换新的伪节点的方法。这样在队列内没有元素时，last->next = newNode，这个last指针和当前first指向同一个节点，那么也就代表fakeFirst->next = newNode（保证在队列为空时，head和last都指向fake节点）。4.3.2 ArrayBlockingQueue实现队列的方式无非两种：链表和数组。上面我们讲的是链表实现了一个阻塞队列，这里我们讲如何通过数组实现一个阻塞队列。在讲数组实现阻塞队列前我们先讲下数组如何实现一个FIFO队列：首先我们需要两个指针，分别是header和tail指针，由于数据结构是数组，因此我们的指针只需要是数组索引（下角标）即可。当出队列一个元素时，header指针向后移动（索引+1），并返回旧header索引处的值，然后将旧heaer索引处的值设为null当入队列一个元素时，tail指针向后移动（索引+1），将新元素添加到tail指针指向的新位置。如果header或tail移动到了数组的尾巴，再移动时就从索引0开始，这样就形成了一个环形数组。如果数组满了，那么就拒绝入队或扩容数组。其代码如下：/** * 通过数组实现一个FIFO队列 * @author coderZoe * @date 2021/12/29 16:46 */ public class QueueTest<E> { /** * 装元素的数组 */ private final Object[] elementData; /** * 当前数组容量 */ private final int capacity; /** * 头指针 用于出队操作 */ private int header; /** * 尾指针 用于入队操作 */ private int tail; /** * 当前队列的实际数量 */ private int count = 0; public QueueTest(int capacity) { this.capacity = capacity; this.elementData = new Object[capacity]; this.tail = -1; this.header = 0; } /** * 入队列 * @param element 元素 */ public void enq(E element){ if(count==capacity){ throw new RuntimeException("队列已满无法入队"); } tail = increment(tail); this.elementData[tail] = element; count++; } /** * 出队列 * @return 返回出队列的元素 */ public E deq(){ if(count==0){ throw new RuntimeException("队列内无元素 无法出队"); } E element = (E) this.elementData[header]; this.elementData[header] = null; header = increment(header); return element; } private int increment(int index){ return (index+1)%capacity; } } 有了这个基础，我们再来看ArrayBlockingQueue的实现，其属性如下：//数组 final Object[] items; //出队列索引 int takeIndex; //入队列索引 int putIndex; //当前数组实际个数 int count; //锁 独占锁 final ReentrantLock lock; //非空条件 用于出队阻塞 private final Condition notEmpty; //非满条件 用于入队阻塞 private final Condition notFull;入队：public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; //加锁 lock.lockInterruptibly(); try { //如果满了就阻塞 while (count == items.length) notFull.await(); //入队 enqueue(e); } finally { lock.unlock(); } } //入队操作 private void enqueue(E x) { final Object[] items = this.items; //填入信息 items[putIndex] = x; //环形数组 if (++putIndex == items.length) putIndex = 0; count++; //通知因empty挂起的出队线程 notEmpty.signal(); }出队：public E take() throws InterruptedException { final ReentrantLock lock = this.lock; //加锁 lock.lockInterruptibly(); try { //如果队列为空 阻塞 while (count == 0) notEmpty.await(); //进行出队 return dequeue(); } finally { lock.unlock(); } } //出队操作 private E dequeue() { final Object[] items = this.items; @SuppressWarnings("unchecked") //出队 旧值设空 释放内存 E x = (E) items[takeIndex]; items[takeIndex] = null; //循环数组 if (++takeIndex == items.length) takeIndex = 0; count--; if (itrs != null) itrs.elementDequeued(); //通知因队列满了而挂起的入队线程 notFull.signal(); return x; }4.3.3 PriorityBlockingQueueJDK中有一个数据结构叫PriorityQueue，即优先队列，这是一个有序的队列（部分有序），使用者在创建的时候需要指明排序方式（或元素自身实现了Comparable）。每次入队的时候，都会排序将元素插入到正确的位置，出队的时候出的是队列内的第一个元素（即排序后最小的元素）。理解PriorityBlockingQueue前我们先实现PriorityQueue，这会使我们更容易理解PriorityBlockingQueue的源码。PriorityQueue本质是一个完全二叉树（二叉堆），且树上的任意一个节点都小于其子节点，因此这样保证了每次出队的时候都是出树顶的数据。PriorityQueue是通过数组实现的，我们可以对二叉树节点进行编号，从上到下，从左到右，那么每一个节点的编号其实就是其所在数组的索引，这样我们就能通过数组索引来访问对应的节点。数组第0个元素就是树顶，其子节点是第1个和第2个元素，第1个元素的子节点是第3和第4个元素，以此类推，第n个元素的子节点是 2n+1和2(n+1)。同样，第n个节点其父节点是(n-1)/2。有了这些概念，我们来看下PriorityQueue是如何入队的：当一个元素入队时，我们会先假装他现在在队列的末尾，然后比较当前节点和父节点的大小，如果小于父节点就将自己和父节点交换，重复此过程一直到当前节点不小于父节点或当前节点是头结点为止，我们将这一流程称为“上浮”。即新节点会不断和当前父节点进行比较，如果小于父节点就会上浮。再来看PriorityQueue是如何出队的：我们知道出队的必然是树顶也即数组0位置的元素，但树顶出队后就需要选拔出新的树顶来占据数组0这个位置，这样又会空出来一个位置，依次类推，会不断的变换树的形状。但无论怎么变，出队都是做了-1操作，即假设当前树节点共n+1个，出队后树节点还剩n个，数组内的很多元素都会移动，最终数组0位置的元素会被新的填充，数组n位置的元素会被清除。这样一看，其实出队就是我们从当前数组中选拔出新的位置0的元素，并且将数组n位置元素安排到恰当的位置。因此出队的逻辑如下：我们先取出数组n位置的元素，命为n，将当前出队的树顶元素得到，命为x。得到x的两个子节点，如果两个子节点都大于n，那么就将n与当前x交换，出队完成。否则如果这两个子节点有比n小的，就将x与较小的那个子节点交换，重复获得当前x的子节点，再次比较，如果一直交换到x不再具有子节点（最后一层），就将x于n交换。这样在树顶元素x与其子节点不断交换的过程中，我们可以成为“下沉”。其入队和出队的代码如下：/** * 优先队列，本质是完全二叉树，通过数组实现 * @author coderZoe * @date 2021/12/30 16:56 */ public class PriorityQueue<E> { private final Object[] elementData; private int size; private final Comparator<? super E> comparator; public PriorityQueue(int capacity, Comparator<? super E> comparator) { this.size = -1; this.elementData = new Object[capacity]; this.comparator = comparator; } /** * 入队 * @param e 入队的元素 */ public void enq(E e){ size++; //判断数组为空的情况 if(size==0){ this.elementData[size] = e; }else { int index = size; //上浮操作 while (index > 0){ int parentIndex = getParentIndex(index); @SuppressWarnings("unchecked") E parent = (E) elementData[parentIndex]; //如果当前节点比父节点小，就将父节点与当前节点交换，交换直到当前节点不大于父节点 if(comparator.compare(e,parent)>=0){ break; } this.elementData[index] = parent; index = parentIndex; } this.elementData[index] = e; } } /** * 出队 * @return 返回头部元素 */ @SuppressWarnings("unchecked") public E deq(){ if(size==0){ return null; } //出队的数据 E head = (E) this.elementData[0]; //尾节点数据 E last = (E) this.elementData[size]; //清空数组尾节点数据 this.elementData[size] = null; size--; //树节点个数大于1个才会需要改变数的结构 if(size!=0){ int headIndex = 0; //下沉直到当前节点不再有子节点位置 while (headIndex < size/2 ){ int minChildIndex = getLeftChildIndex(headIndex); E minChild = (E) this.elementData[minChildIndex]; //得到更小的子节点 int rightChildIndex = getRightChildIndex(headIndex); if(rightChildIndex < size && comparator.compare((E)elementData[rightChildIndex],minChild) < 0){ minChildIndex = rightChildIndex; minChild = (E)elementData[rightChildIndex]; } //如果更小子节点都大于last节点，将last节点与当前节点交换 出队完成 退出循环。 if(comparator.compare(minChild,last)>0 ){ break; } //否则 将当前节点与更小的那个子节点交换 this.elementData[headIndex] = this.elementData[minChildIndex]; headIndex = minChildIndex; } //会走到这代表当前节点没有子节点了，将当前节点与last节点交换 this.elementData[headIndex] = last; } return head; } private int getParentIndex(int index){ return (index-1)/2; } private int getRightChildIndex(int index){ return 2*index+2; } private int getLeftChildIndex(int index){ return 2*index+1; } }有了这个基础后我们再来看JDK中PriorityBlockingQueue的实现：PriorityBlockingQueue类在PriorityQueue中多加了一个独占锁和一个信号量，独占锁是为了保证线程安全的，而信号量是保证在队列为空时出队进行阻塞。另外因为PriorityBlockingQueue是无界集合，即会自动进行扩容，所以不存在入队时的挂起，只会有出队时的挂起。入队：public boolean offer(E e) { if (e == null) throw new NullPointerException(); final ReentrantLock lock = this.lock; //加锁 lock.lock(); int n, cap; Object[] array; //扩容 while ((n = size) >= (cap = (array = queue).length)) tryGrow(array, cap); try { Comparator<? super E> cmp = comparator; //上浮 if (cmp == null) siftUpComparable(n, e, array); else siftUpUsingComparator(n, e, array, cmp); size = n + 1; //通知因队列为空而出队时挂起的线程 notEmpty.signal(); } finally { lock.unlock(); } return true; } //上浮函数 这里与我们之前写的逻辑基本一样 private static <T> void siftUpUsingComparator(int k, T x, Object[] array, Comparator<? super T> cmp) { while (k > 0) { int parent = (k - 1) >>> 1; Object e = array[parent]; if (cmp.compare(x, (T) e) >= 0) break; array[k] = e; k = parent; } array[k] = x; }出队：public E poll() { final ReentrantLock lock = this.lock; //加锁 lock.lock(); try { //出队 return dequeue(); } finally { lock.unlock(); } } //出队操作 private E dequeue() { int n = size - 1; if (n < 0) return null; else { Object[] array = queue; E result = (E) array[0]; E x = (E) array[n]; array[n] = null; Comparator<? super E> cmp = comparator; //下沉 if (cmp == null) siftDownComparable(0, x, array, n); else siftDownUsingComparator(0, x, array, n, cmp); size = n; return result; } } //下沉操作 与我们之前写的逻辑基本一样 private static <T> void siftDownUsingComparator(int k, T x, Object[] array,int n,Comparator<? super T> cmp) { if (n > 0) { int half = n >>> 1; while (k < half) { int child = (k << 1) + 1; Object c = array[child]; int right = child + 1; if (right < n && cmp.compare((T) c, (T) array[right]) > 0) c = array[child = right]; if (cmp.compare(x, (T) c) <= 0) break; array[k] = c; k = child; } array[k] = x; } }阻塞出队：public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); E result; try { //做了一个阻塞挂起 如果当前队列为空 就用条件变量挂起 while ( (result = dequeue()) == null) notEmpty.await(); } finally { lock.unlock(); } return result; }4.4 CopyOnWriteArrayListCopyOnWriteArrayList的源码是很简单的，但是要看懂源码需要先了解CopyOnWrite思想，这一思想及其的重要，其使用场景也非常广泛，如OS下fork一个子进程，Redis的RDB，Kafka的客户端内存缓冲区。在说COW前我们先说下如何实现一个线程安全的List集合，比较简单的方法就是加锁，如果想效率比较高可以加读写锁。但如果是读多写少的场景，我们加读写锁会有什么影响呢？首先我们知道读写锁的写写操作和读写操作是互斥的，当某个线程在写入的时候，不允许其他线程读取。这样假设我们在高并发读取的情况下偶尔插入一条写请求，那么其他读线程可能都会阻塞，这肯定不满足我们的需求的。我们更想要的是一种在读取的时候能够不阻塞，并且在写入的情况下也不会影响读取的List。这就引入了COW，COW就是写时复制，它被广泛的应用于读多写少的场景。在写入的时候会拷贝一份原始数据的副本，然后在副本数据上执行修改（写入）操作，此时读取操作还是读的原始数据，并不会因为写入受影响，因此也不会阻塞读取。修改完成后再将原始数据替换为副本数据。然后Java下会通过volatile关键字通知数据被修改，保证读线程对修改的可见性。但是需要注意的是，CopyOnWriteArrayList只适合读多写少的场景，因为在写入的时候，写写线程也是互斥的，同时每个写线程还会执行一次数据复制操作，因此更耗时。下面我们看下CopyOnWriteArrayList的源码实现首先CopyOnWriteArrayList的属性很少，关键的只有如下两个：public class CopyOnWriteArrayList<E>{ //独占锁，用于写写互斥 final transient ReentrantLock lock = new ReentrantLock(); //List的底层实现，就是数组 加了volatile关键字，保证修改的可见性 private transient volatile Object[] array; }add 操作public boolean add(E e) { //加独占锁，写写互斥 final ReentrantLock lock = this.lock; lock.lock(); try { //获取数组 Object[] elements = getArray(); int len = elements.length; //cow思想，copy一份原始数据 Object[] newElements = Arrays.copyOf(elements, len + 1); //将新的数据插入到复制的副本数据中 newElements[len] = e; //将原始数据替换为副本数据 setArray(newElements); return true; } finally { lock.unlock(); } } final Object[] getArray() { return array; } final void setArray(Object[] a) { array = a; }remove操作public E remove(int index) { //加独占锁 final ReentrantLock lock = this.lock; lock.lock(); try { //获取数组 Object[] elements = getArray(); int len = elements.length; E oldValue = get(elements, index); //numMoved是删除数组的下角标位置(这个下角标是倒着数的位置，比如要删除最后一个元素，此时numMoved就是0) int numMoved = len - index - 1; //删除的是最后一个元素 if (numMoved == 0) //直接截取1~length-1的数组即可，将最后一个位置裁掉 setArray(Arrays.copyOf(elements, len - 1)); else { //否则，代表删除的元素不是最后一个 Object[] newElements = new Object[len - 1]; //执行两段拷贝，将要删除的元素从数组中剃掉 //0~index和 index+1~length System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); //替换新的Array setArray(newElements); } return oldValue; } finally { lock.unlock(); } }get操作public E get(int index) { //直接读取 不加锁 return get(getArray(), index); }​
JUC源码详细分析之Part2——《锁》本系列笔记笔者将会详细的讲解JUC中核心类的源码，其中基于的JDK版本为1.8.0_291。由于JUC内容很多，因此笔者将笔记拆为四部分：JUC源码详细分析之Part1——《CAS和原子类型》JUC源码详细分析之Part2——《锁》JUC源码详细分析之Part3——《常见的线程安全集合》JUC源码详细分析之Part4——《线程池》另外，本系列笔记部分内容参考自《深入浅出 Java Concurrency》3. 锁锁是JUC中最核心的内容，在本章我们将讲解JUC下常见的锁，以及介绍下著名的AQS框架。在讲解源码前，我们先来介绍一些基本的但常见的概念。锁是什么？解决了什么问题？在多线程下，我们希望有些代码是线程安全的执行的，我们将这样的代码行叫临界区。锁保证了临界区的代码可以像单条指令一样原子的执行，当某个线程持有锁的时候，就可以执行临界区的代码，其他线程此时是不可以的，只有这个持有锁的线程释放锁才能继续由下一个线程执行。悲观锁，乐观锁悲观锁：每次都以悲观的态度假设最坏的情况，每次拿数据的时候都认为别人会修改，所以每次在操作前会先上锁，操作后再将锁释放，Java中的synchronized和ReentrantLock都是悲观锁。乐观锁：每次都是乐观的态度假设当前是最好的情况，每次拿数据的时候都认为别人不会修改，所以不会上锁，但是在修改的时候会判断别人有没有修改这个数据（通过CAS和版本号实现），Java中的AtomicInteger就是乐观锁的一种实现，依赖了CAS。我们一般也认为悲观锁是阻塞锁，而乐观锁是非阻塞锁。非阻塞指的是不阻塞其他线程，即自己可以失败可以阻塞挂起，但不能因为自己的失败和挂起影响其他线程导致其他线程失败挂起。很多乐观锁的实现都依赖于CAS，但CAS本质是没有锁的，虽然叫乐观锁但它并没有锁。CAS只是通过比较和替换的方式，只有比较之后符合要求才进行替换，其中比较和替换是个原子操作，这其中并没有上锁以及并不会干扰任何其他线程，通常CAS都是自旋的，不断的尝试比较和替换，所以这里可以看到CAS是非阻塞锁，他的执行并不会干扰其他线程。由于CAS没有加锁，所以需要通过CAS同步修改的变量都应该使用volatile修饰符，这样保证变量的修改可以被其他线程看到。通常而言乐观锁应用于写少读多的情况，悲观锁应用于写多读少的情况。可重入锁，不可重入锁所谓可重入锁就是如果一个线程持有了锁，那么它可以重复执行需要这种锁的临界区代码，可以进行重复加锁。反之为不可重入锁。如：public void test(){ lock.lock(); try { while (x > 10){ x--; test(); } }finally { lock.unlock(); } }独占锁，共享锁所谓独占锁和共享锁是对资源而言的，如果某个资源只能同时被一个线程访问，那么就需要使用独占锁。而如果可以被所有线程一起访问，那么就可以使用共享锁。其中独占锁又叫互斥锁。大多数情况下我们在说锁的时候都是默认独占锁。公平锁，非公平锁如果获取一个锁是按照请求的顺序得到的，那么就是公平锁，否则就是非公平锁。这里有一篇很棒的文章，可以方便大家对Java中的锁有个大体了解：不可不说的Java“锁”事 - 美团技术团队 (meituan.com)3.1 Lock与AQSJava下的Lock接口定义如下：public interface Lock { //获取锁，如果锁不可用，出于线程调度目的，将禁用当前线程，并且在获得锁之前，该线程将一直处于休眠状态。 void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //释放锁 void unlock(); Condition newCondition(); }JUC下大部分锁的实现均是继承自该接口，其中Lock比较重要的两个函数是lock和unlock，我们在讲具体实现类的时候也主要讲这两个接口的实现。在讲JUC下锁的实现类前，我们先想下如果让你设计一个锁，实现lock()方法，你需要做什么？首先要判断是否允许当前线程获取锁，对于是否允许获取锁，得有一个状态位，这个状态位的修改必须是线程安全的。其次如果允许获取锁，就获取，否则就阻塞等待或者直接获取失败。若线程需要阻塞等待，那么就需要一个阻塞等待的队列。最后阻塞的时候，线程往往需要挂起不消耗资源。当状态位由不允许获取变更到允许获取时，得唤醒队列里的线程，并且线程得从阻塞队列中移除出去。综上我们发现lock()方法需要以下条件：一个标志锁同步状态的原子操作的状态位。一个有序的队列，方便管理所有阻塞等待的线程。线程未获得锁，等待时可能需要挂起，当可以获得锁时再唤醒等待的线程。即线程的挂起和唤醒功能。而这便是AQS框架的核心。AQS全称AbstractQueuedSynchronizer，它是JUC中最复杂最核心的一个类，后面我们讲到的CountDownLatch/FutureTask/ReentrantLock/RenntrantReadWriteLock/Semaphore等都是在其基础上实现的。在AQS中锁的同步状态是通过volatile int来实现的public abstract class AbstractQueuedSynchronizer{ //... private volatile int state; //... }看到volatile的时候很多同学应该也明白这个状态位线程安全的修改都是依赖CAS。AQS中提供了如下方法来访问状态位：//获得状态位 protected final int getState() //直接修改状态位 protected final void setState(int newState) //通过CAS线程安全的修改状态位 protected final boolean compareAndSetState(int expect, int update)线程的挂起和唤醒通过LockSupport类来实现的，LockSupport底层是JNI，其性能比Java原本的线程挂起和唤醒效率更高。LockSupport的API很简单：//挂起当前线程 public static void park() //唤醒当前线程 public static void unpark(Thread thread)而阻塞队列在AQS中使用CLH队列的变体，简单来讲就是一个FIFO的包含头尾节点的双向的队列。队列节点主要包含属性如下：class Node { //节点的等待状态，主要包含SIGNAL/CANCELLED/CONDITION/PROPAGATE等状态 volatile int waitStatus; //前置节点 volatile Node prev; //后置节点 volatile Node next; //绑定的线程 volatile Thread thread; //主要用于Condition Node nextWaiter; }同时AQS持有队列的头尾节点：public abstract class AbstractQueuedSynchronizer{ //... private transient volatile Node head; private transient volatile Node tail; //... }有了这些基础后，我们就可以简单的实现一个锁了，我们使用AQS框架，模拟一下lock()的简单实现调用lock()方法，通过锁的同步状态位判断当前线程是否允许获得锁，如果允许获取就结束，否则进入步骤2由于不允许获取锁，将当前线程封装为Node节点，加入到CLH队列，判断当前线程是否需要挂起等待如果需要挂起等待就调用LockSupport.park()方法挂起当前线程，否则就持续不断的尝试获取锁。而unlock()流程也很简单：调用unlock()，释放锁，修改锁的状态位如果后继节点是挂起等待的，就将后继节点唤醒，让他继续重新尝试获取锁。看，借助AQS我们已经很轻松的实现了一个锁，后面我们讲的所有不同锁的实现类都与上述流程基本相同。另外贴一下AQS框架的整体架构图：上图中有颜色的为Method，无颜色的为Attribution。我们将AQS框架分为五层，自上而下由浅入深，从AQS对外暴露的API到底层基础数据。对于自定义的锁继承AQS的时候只需要重写第一层的方法即可，不需要关心底层的实现逻辑。自定义锁对于加锁和释放锁会进入第二层，如果锁获取失败就会进入第三和第四层对等待队列 进行处理，而这些处理方式均依赖于第五层的基础数据提供层。如果你还是不太了解AQS，没关系，看完下面的实现类我想你会对AQS有更清晰的认识。3.2 ReentrantLockReentrantLock是JUC提供的一个可重入锁和独占锁。其使用场景大致如下：public class LockDemo{ private int value; private Lock lock = new ReentrantLock(); public int get(){ this.lock.lock(); try { return this.value; }finally { this.lock.unlock(); } } public void set(int value){ this.lock.lock(); try { this.value = value; }finally { this.lock.unlock(); } } public int getAndSet(int value){ this.lock.lock(); try { int temp = this.value; this.value = value; return temp; }finally { this.lock.unlock(); } } public boolean compareAndSet(int expect,int update){ this.lock.lock(); try { if(value==expect){ value = update; return true; }else { return false; } }finally { this.lock.unlock(); } } public int getAndIncrement(){ this.lock.lock(); try { return this.value++; }finally { this.lock.unlock(); } } public int getAndDecrement(){ this.lock.lock(); try { return this.value--; }finally { this.lock.unlock(); } } public int incrementAndGet(){ this.lock.lock(); try { return ++this.value; }finally { this.lock.unlock(); } } public int decrementAndGet(){ this.lock.lock(); try { return --this.value; }finally { this.lock.unlock(); } } }可以看到我们通过锁实现了一个AtomicInteger。3.2.1 lock下面我们就重点分析一下ReentrantLock加锁的原理public class ReentrantLock implements Lock{ private final Sync sync; }ReentrantLock内部只有一个有意义的属性sync，其中Sync类是其内部类，这是个抽象类，继承自AQS，该类有两个实现类分别是FairSync和NonfairSync，即公平锁和非公平锁。默认的new ReentrantLock构造方法构造的是非公平锁public ReentrantLock() { sync = new NonfairSync(); }非公平锁的加锁会比公平锁稍微复杂一丢丢，为了源码流程的方便，我们先看公平锁的加锁过程：当调用lock.lock()时，本质上调用的是sync.lock()public void lock() { sync.lock(); }我们现在看公平锁，因此调用的是公平锁的lock()static final class FairSync extends Sync { //... final void lock() { acquire(1); } //... }因此核心就是acquire(1)方法，这个方法是AQS实现的：public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这个方法实际做了四个操作：tryAcquire(1) 尝试获取锁，如果能获得就直接返回，否则进行步骤2addWaiter(Node.EXCLUSIVE) 将当前线程封装成Node节点，并将节点加入到CLH队列的最尾端acquireQueued() 自旋尝试获得锁，如果获得不了，就判断自己是否应park，如果应该就park，否则就一直尝试获取锁selfInterrupt()设置中断位下面我们会一个方法一个方法的看源码：tryAcquire由FairSync实现，其源码如下：//tryAcquire方法用于尝试获取锁 protected final boolean tryAcquire(int acquires) { //得到当前请求线程 final Thread current = Thread.currentThread(); //获得当前AQS的状态(其实也是锁的状态，即前面说的一个状态位标识当前是否允许获取锁) int c = getState(); if (c == 0) { //当前锁没被占用，可以获取锁 //如果阻塞队列确实是空（或头结点是当前线程），并且将锁的状态设置为1成功后（即更改锁的状态，避免其他线程进来） //这里对于状态值的修改是通过CAS实现的 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { //那么就将当前线程设为当前正在独占（运行）的线程，即AQS中会有个exclusiveOwnerThread字段，用于存储独占锁当前正在运行的线程 //exclusiveOwnerThread这个字段用于下面的可重入判断 setExclusiveOwnerThread(current); //此时线程获取锁成功，返回true，即可以获得锁 return true; } } //否则如果当前获取锁的线程是当前线程的话，那么也允许获取锁，可见这是一个可重入锁 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; //对于重复获取锁的线程，将状态位+1 //溢出校验 int值溢出 if (nextc < 0) throw new Error("Maximum lock count exceeded"); //这里对于状态值得修改不需要加锁或CAS，因为能走到这里，肯定正在运行的线程是当前线程，必不可能有其他线程进来一起修改这个值 setState(nextc); //可重入锁也返回true，代表获取锁成功 return true; } //否则返回失败，代表获取锁失败 return false; }//这个方法用于判断当前线程是否满足获取锁，它是公平锁和非公平锁主要的区别 public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; //h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); //翻译过来即 如果头尾不相等并且（头的下一个节点是空或者头结点的下一个节点不等于当前线程） 则返回true //这样不利于理解，我们对这个逻辑整体取反，即如果(头尾相等) || (头节点的下一个节点不是空 并且头结点的下一个节点是当前线程) //这就很好理解了 return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }addWaiter源码如下：//会执行这个函数意味着获取锁失败了，那么就开始将当前线程加入CLH队列，排队等待获取锁 //将当前线程封装为Node对象（Node对象即为我们之前说的CLH队列每个元素的类型），并将其加入到CLH队列的队尾，并返回构造的这个Node对象 private Node addWaiter(Node mode) { //这里的传参mode是Node.EXCLUSIVE，表示是独占模式 //在new Node中，第一个参数被赋予了thread字段，第二个参数被赋予了nextWaiter字段，也即认为独占锁的nextWaiter是空的，也即独占锁不具有条件 Node node = new Node(Thread.currentThread(), mode); Node pred = tail; //如果当前队列的尾节点不等于空 if (pred != null) { //那么就让我们之前构造的节点(该节点盛装当前线程)的pre指针指向队列尾节点 node.prev = pred; //同时通过CAS将当前节点设为新的队列尾节点 if (compareAndSetTail(pred, node)) { //成功后将之前尾节点的next指针指向当前节点 pred.next = node; //完全完成后当前节点已经是尾节点，将构造的当前节点返回 return node; } } //否则，这里的否则包括： //1.尾节点等于空 //2.尾节点不等于空但是CAS操作将当前节点设置为尾节点失败了 //那么就执行enq函数,enq函数做的也是将当前节点置为尾节点 enq(node); return node; }//在将当前节点设置为尾节点失败后，通过enq函数，保证将当前节点设为尾节点一定要成功 //首先将当前节点设为尾节点失败有两个原因： //1.队列是空的(尾节点不存在) //2.队列不为空，但CAS操作将当前节点设为尾节点失败了,一般是有其他线程在抢 //针对第一个问题就创建一个队列(创建一个头尾傀儡节点)，这里为了保证创建成功会死循环一直创建 //针对第二个问题 也是无限尝试，直到通过CAS操作尝试成功。 private Node enq(final Node node) { //死循环，无限尝试，直到成功 for (;;) { //获得当前尾节点 Node t = tail; if (t == null) { //如果尾节点是空的话(代表整个CLH队列是空的不存在的或还没初始化的) //那么就创建一个无意义节点，我们将这个无意义节点称之为傀儡节点 //通过CAS操作将傀儡节点设为CLH队列的头 if (compareAndSetHead(new Node())) //并让尾节点也等于头 //此时完成了CLH的初始化，即只有一个无意义的傀儡节点，这个节点既是队列头也是队列尾巴 tail = head; } else { //否则 //一旦进入了这个else语句代表尾节点不再是空，也即CLH完成了初始化，如果初始化未成功（因为是CAS操作很可能不成功），那么就死循环初始化直到成功，成功后才能进入else //将当前节点的pre指针指向队列的尾节点 node.prev = t; //CAS操作设置当前节点为队列尾节点(如果不成功，死循环操作，无限尝试，直到成功) if (compareAndSetTail(t, node)) { //之前队列节点的next指针 t.next = node; return t; } } } }这里需要说明一下：在初始的时候AQS内的CLH队列是没被创建的，只有需要的时候才会被创建。另外我们知道AQS具有队列的头尾指针，其中这个头指针指向的是个傀儡节点，永远一直都是傀儡节点。也即初始的时候，会创建一个无意义节点，这时头指针和尾指针都指向这个无意义节点，当有Node入队的时候，就将tail.next = node; tail = node;，替换新的尾节点，但此时头节点的指向依然没有变，还是无意义节点。因此真正的头节点其实是head.next，头节点是假的这个信息十分重要，在前面的hasQueuedPredecessors函数其实就已经使用到了这一信息，后面我们还会频繁的用到，希望大家能在此处就记住。acquireQueued源码如下：//走到这里意味着线程获取锁失败，并且线程已经封装为节点，当前节点已经在CLH队列最尾端了 //参数node即addWaiter方法返回的节点，也即封装了当前线程的节点 final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { //中断标志位 boolean interrupted = false; //无限尝试 for (;;) { //获得当前节点的前置节点(即pre指针指向的节点) final Node p = node.predecessor(); //如果当前节点的前置节点是头结点，那么就尝试获取锁 //这里我们又用到了头节点是傀儡节点这一信息 //如果当前节点的前置节点是头结点，那么就可以认为当前节点是真正的头结点，也即队列的第一个元素 //那么既然是队列的第一个元素，就具有获取锁的资格，因此执行tryAcquire函数获取锁 if (p == head && tryAcquire(arg)) { //锁获取成功后，将当前节点设为头节点(新的傀儡节点，因为当前节点已经获得锁了，所以这个节点本身已经没有意义了，只是傀儡节点的意义了。) setHead(node); //将之前的头节点释放掉 p.next = null; // help GC failed = false; return interrupted; } //走到这里意味着当前节点不是真实头结点或当前节点是真实头结点但获取锁失败 //判断当前节点是否应该挂起，如果应该挂起就挂起当前节点 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) //理论上如果线程获得了锁，那么failed就必然是false，如果线程没有获得锁，那么就应该for死等待获得锁 //会进入到这里，就说明线程没有获得锁并且被中断了，此时我们就需要更改节点的状态并从CLH队列中移除 cancelAcquire(node); } }//判断当前节点是否应该挂起，如果需要挂起就返回true，否则返回false private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { //得到当前节点的前置节点状态 int ws = pred.waitStatus; //如果是signal状态，也即当前节点的前置节点也是阻塞未获取锁的，正常来说只有前面节点获得了锁，并且释放了锁，当前节点才能获得锁 //因此既然前置节点也是阻塞的，那么当前节点应该挂起等待(等到被前置节点唤醒) if (ws == Node.SIGNAL) return true; //如果当前节点的前置节点状态大于0，也即cancelled状态，那么从当前节点开始，往前推，删除中间所有的状态大于0(即被cancelled的节点) //相当于每次走到这个函数时都会重新整合CLH链表，剔除无意义的节点 if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { //走到这个else代表当前节点的前置节点状态是0(因为ReentrantLock无-2，-3状态) //因为前置节点后面有节点(即当前节点)需要被处理(适当的时候被唤醒),因此将当前节点的前置节点状态由0改为-1 //这里0和-1的区别是： //如果一个节点状态是0,那么当他获得锁并且执行完指令释放锁之后，他会认为自己没有需要处理的继任节点，也即释放完锁不需要唤醒其他节点 //但对于节点状态是-1，那么当他获得锁执行完任务并且释放锁后，他会唤醒自己的继任节点，让继任节点获得锁开始运行 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } //这里需要明确的一点是由于acquireQueued()方法是for(;;)形式，也即可能会不断的调用当前函数，所以某次返回false不代表当前线程不需要休眠，可能某次执行的是ws> 0 的分支，重排完队列后，下一次再进来就是执行ws == Node.SIGNAL，此时就需要挂起了。 return false; }//取消获取锁，在锁获取失败时执行 private void cancelAcquire(Node node) { if (node == null) return; //释放当前节点绑定的线程 node.thread = null; //获得当前节点的前置节点，判断当前节点前置节点是否是CANCELLED状态， //如果是那么再往前找，如此循环，一直找到一个状态小于等于0的节点，将当前节点的前置节点设为那个节点 //这里其实就是在剔除当前节点前面的无效等待节点（状态大于0的节点都是无效节点） Node pred = node.prev; while (pred.waitStatus > 0) node.prev = pred = pred.prev; //此时pred已经是最靠近当前节点的有意义的前节点 Node predNext = pred.next; //将当前节点状态设为CANCELLED，避免当前节点绑定的线程被唤醒 node.waitStatus = Node.CANCELLED; //如果当前节点是尾节点，由于我们做了一些无意义节点的剔除工作，此时就需要重设一个有意义的尾节点 //很容易得出pred节点就是现在真实有意义的尾节点（前提是当前节点是尾节点） if (node == tail && compareAndSetTail(node, pred)) { //同时将pred节点(新的尾节点)的next指针指向空 compareAndSetNext(pred, predNext, null); } else { //如果当前节点不是尾节点或者将pred节点设为尾节点失败 int ws; //这个if比较长，但其实就说了一句话，如果pred节点是SIGNAL(有后续需要通知的节点)，那么就如何如何 //我们可以一个一个看 //pred != head 前置节点不是傀儡头 并且 //pred.waitStatus== Node.SIGNAL 前置节点的状态是SIGNAL 或者 //ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL) 即如果前置节点不是SIGNAL但他是<=0的，那么就把它设置成SIGNAL状态 //所以归根结底，就是让pred状态为SIGNAL状态 //这里将pred状态设置为SIGNAL的原因也很简单，因为当前节点不是尾节点，那么就代表当前节点还有后继待唤醒的节点，但当前节点以及前面的一些连续无效节点要被删了，此时就需要新的节点来作为它们的前置节点，然后唤醒这些后继节点。因此将waitStatus设为SIGNAL，代表有后继需要唤醒的节点。 if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { //走到这里代表pred的状态是SIGNAL了，如果当前节点的下一个节点不是CANCELLED，那么就将pred节点的next指针设为当前节点的next //这样，链表就删除掉了当前节点和当前节点之前的那些CANCELLED状态节点， Node next = node.next; if (next != null && next.waitStatus <= 0) compareAndSetNext(pred, predNext, next); } else { //否则唤醒继任节点 unparkSuccessor这个函数我们会在unlock时分析，目前只需要记住他是用来唤醒继任节点的。 unparkSuccessor(node); } node.next = node; // help GC } } //这里做个总结，这个函数其实就是在做一件事：整理CLH队列，剔除无意义节点。当然它只是剔除了自己以及自己之前的那些连续的无意义节点，及时的剔除无意义节点，有利于提高CLH队列的遍历效率selfInterrupt源码如下：//复原线程的中断标志 //只有线程中断过才会进这个函数 之前的parkAndCheckInterrupt()函数清空了线程的中断位 //这里相当于重新赋值线程中断位 之前清空的现在赋值回去 static void selfInterrupt() { Thread.currentThread().interrupt(); }看完了公平锁整个加锁逻辑，我们再来看下非公平锁：非公平锁的加锁只有两处代码不同，第一处是lock方法：static final class NonfairSync extends Sync { //... final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } //... }在公平锁中，lock方法会直接调用acquire，而acquire会调用tryAcquire方法，这个方法在获得锁的时候会判断当前CLH队列是否为空或者当前节点是否为头节点，换句话说，公平锁严格要求获取锁的节点是队列的头节点，别的节点不能获得锁。但非公平锁不同，非公平锁在lock的时候会直接立马尝试获得锁，要是能立马得到锁成功就返回，否则才会执行acquire，另外两者的tryAcquire方法也不同：protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }不同点只有一处，非公平锁没有执行hasQueuedPredecessors函数，而这个函数就是保证公平的核心函数。后面所有的内容均与公平锁一致，比如入CLH队列的末尾节点，自旋获得锁，挂起等待等。可以看见，公平锁与非公平锁主要区别就是在获取锁的时候，非公平锁可以先尝试着直接获取，不等其他任何节点，如果要是获取锁失败，依然会乖乖的将自己置为等待队列的尾节点等着获取锁。公平锁比较守规矩，每次获取锁的时候只有自己是队列的第一个时才会尝试获取锁。非公平锁有可能提高并发的性能，这是因为通常情况下挂起的线程重新开始与它真正开始运行，二者之间会产生严重的延时。因此非公平锁就可以利用这段时间完成操作。这是非公平锁在某些时候比公平锁性能要好的原因之一。3.2.2 unlock与lock一样，当我们调用lock.unlock时，实际调的是 sync.release()public void unlock() { sync.release(1); }其核心实现为AQS的release方法：//锁释放的核心方法，其步骤就两步：第一步是修改锁的状态位，第二步是唤醒等待的下一个线程 public final boolean release(int arg) { //尝试释放锁，由子类实现，其本质是修改状态位state if (tryRelease(arg)) { //如果锁释放成功并且当前节点的状态不等于0(其实就是<0，代表还有后继节点需要唤醒)，那么就直接线程的唤醒操作 Node h = head; if (h != null && h.waitStatus != 0) //线程的唤醒操作 unparkSuccessor(h); return true; } //否则，也即尝试释放锁失败直接返回false return false; }//子类Sync实现，尝试释放锁，本质就是在修改state值，将state-1 protected final boolean tryRelease(int releases) { //由于传入的releases == 1，因此这里就是state-1 int c = getState() - releases; //如果释放锁的线程不是当前持有锁的线程直接抛错 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; //c代表的是当前持有锁的数量（可重入的时候每次重入也会+1），如果c==0其实就是代表当前持有锁的线程是0个，那么就代表当前锁已经被释放掉了，那么将exclusiveOwnerThread清空并返回true。 if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }//线程的唤醒，非常非常非常核心的代码，本质就是从CLH队列中找到下一个有效头节点唤醒它 private void unparkSuccessor(Node node) { //将当前头节点的状态进行修改，设为0。 //这里的意义我也没看懂，可能是怕多线程同时执行release方法，这样CAS操作设为0后就避免别的线程也操作后续代码 int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); //这是核心逻辑代码，这里的目的就是从CLH队列中找出新的有意义头节点（会有些没意义的节点，比如状态为CANCEL的节点） Node s = node.next; //这里的逻辑核心是指找到最接近node（此时node是头节点）的有意义节点 //找的方法是倒着找，从尾巴开始找，一旦找到有意义的就赋给s，然后再往前找，越来越逼近node，最后s就是最逼近node的有意义节点 //我们需要先理解变量s现在等于node.next，由于node已经执行完释放锁了，那么node也就没了意义，此时node.next就是这个CLH的头节点，但这个头节点有没有意义呢？如果s==null或者s.waitStatus>0，此时s就是无意义节点，就需要寻找真正的有意义节点 if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } //找到头节点后，唤醒头节点 if (s != null) LockSupport.unpark(s.thread); }这里可能需要解释的为：tryRelease中的if(c==0)，这里可能很多人会有疑问，既然是独占锁，那么应该只有一个线程会持有锁，c应该只等于1，那么c-1不一定等于0吗，为什么还判断c==0。这个原因很简单，就是可重入，在可重入的时候c也会增加。如果可重入的锁释放了，并不代表没有线程占用锁了，可能这个线程还在占用，没完全释放锁。看完了ReentrantLock的加锁与释放锁源码，我们再举个例子说明一下多线程下使用独占锁时线程的生命周期：假设你是一个线程小Y，你需要执行一段代码，但这段代码被上了锁，每个线程都必须拿到锁才能执行。也即你需要先执行lock.lock()这代表申请锁，当你执行后，会先判断当前锁有没有被别人持有（也即这段代码有没有别的线程在运行）。很不幸，你没能获得锁，那按照规则，你被包装成了一个节点，加进了等待队列，由于你是后来的你进了队列的最尾端。这一进队列不要紧，你发现原来队列里有那么多跟你一模一样的兄弟，它们也都在排着队等待获得锁然后执行代码。现在队列中正在获得锁执行的是线程A。由于你没有获得锁，也不是当前的头节点，很快有一个凶神恶煞的管理员过来跟你说：“小子，赶紧给我睡会，别在这不干活又消耗CPU的”，你也不敢多言，只好准备睡了。只不过在睡之前，你发现你前面的兄弟早就已经熟睡了，并且并不知道什么时候，你前面兄弟的节点状态由0改为了-1。注 就是对应这段代码：for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; }你也不知道睡了多久，总之当你醒来的时候，现在队列已经是这样了：你睡眼朦胧的问你的前置节点X大哥，是它把你唤醒的吗，X回答是的，并说道：现在到你了小Y，你现在是新的头节点了，你可以获得锁执行代码了。由于从刚才睡着的位置被唤醒，因此你还是在执行for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; }不同的是，你现在是新的头节点了，此时再一次for循环的时候，你会进入第一个if分支，你将自己设为头节点代表获得了锁，然后开始噼里啪啦的执行加锁的那段代码。当你执行完代码后，紧接着执行一个叫lock.unlock()的代码，有人告诉你，这是代表你现在执行完了，可以释放锁了，然后你要像X大哥那样，也唤醒你的后继节点，让它持有锁来执行代码吧。你如是照做，释放了锁，并执行unparkSuccessor函数，唤醒了你的后继节点Z。此时Z睡眼惺忪的问你：是你把我唤醒的吗Y大哥。你摸摸它的头，觉得这个场景似曾相识，然后回答道：是的，现在到你了小Z，你现在是新的头节点了，你可以获得锁执行代码了。3.3 Condition很多时候我们的线程同步关系不仅是锁竞争那么简单，有时还附带条件。比如现在有一个生产线程和一个消费线程，生产线程会生产资源到资源池，消费线程也会从资源池中消费资源。但资源池是有一定大小限制的。当资源池满了以后，生产线程就需要停止生产，而一旦消费线程消费掉资源导致资源池又有空位后，就可以唤醒生产线程继续生产。同样的道理，当资源池没资源可消费时，消费者线程就需要停止消费，而一旦生产者线程又生产新资源后就可以唤醒消费者线程继续消费。可以看到，上述案例存在两个线程间在一定条件的同步操作，这时就可以使用我们的Condition，Condition就是条件变量。public class BreadDemo { private ReentrantLock lock = new ReentrantLock(); private Condition notFull = lock.newCondition(); private Condition notEmpty = lock.newCondition(); private Object[] breadHouse; private int breadHouseCapacity; private int head = 0; private int tail = 0; private int count = 0; public BreadDemo() { this(10); } public BreadDemo(int breadHouseCapacity) { this.breadHouseCapacity = breadHouseCapacity; this.breadHouse = new Object[breadHouseCapacity]; } public Object consumer() throws InterruptedException { lock.lock(); try { while (count==0){ notEmpty.await(); } Object bread = breadHouse[head]; breadHouse[head] = null; //help GC if(++head==breadHouseCapacity){ head=0; //回到数组起始 循环使用 } --count; System.out.println(Thread.currentThread().getName()+" 消费了一个面包，目前剩余:"+count); notFull.signalAll(); return bread; }finally { lock.unlock(); } } public void product(Object bread) throws InterruptedException{ lock.lock(); try { while (count==breadHouseCapacity){ notFull.await(); } breadHouse[tail] = bread; if(++tail==breadHouseCapacity){ tail = 0; } ++count; System.out.println(Thread.currentThread().getName()+" 生产了一个面包，目前剩余:"+count); notEmpty.signalAll(); }finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { BreadDemo breadDemo = new BreadDemo(); Thread[] threads = new Thread[100]; for(int i = 0; i < 50; i++){ threads[i] = new Thread(()->{ try { breadDemo.consumer(); breadDemo.consumer(); Thread.sleep(100); breadDemo.consumer(); } catch (InterruptedException e) { e.printStackTrace(); } }); } for(int i = 50; i < 100; i++){ threads[i] = new Thread(()->{ try { Thread.sleep(100); breadDemo.product(new Object()); Thread.sleep(100); breadDemo.product(new Object()); Thread.sleep(100); breadDemo.product(new Object()); } catch (InterruptedException e) { e.printStackTrace(); } }); } for(Thread thread:threads){ thread.start(); } for(Thread thread:threads){ thread.join(); } System.out.println("finish"); } }如上所示，我们使用ReentrantLock和Condition创建了一个面包工厂，并同时让50个线程生产面包和50个线程消费面包。我们通过Lock创建了两个Condition，一个是notFull条件，一个是notEmpty条件。一旦面包房面包满了后，notFull条件就不满足，因此需要阻塞notFull.await()。而一旦消费一个面包后，当前面包房又是notFull状态了，因此通过notFull.signal()唤醒因为notFull条件被阻塞的生产者。当我们调用lock.newCondition()时，其实是创建了一个ConditionObject对象，ConditionObject是AQS类的内部类。其实ConditionObject也可以算得上是一个小的AQS，它里面也维护了一个队列：public class ConditionObject implements Condition, java.io.Serializable { private static final long serialVersionUID = 1173984872572414699L; /** First node of condition queue. */ private transient Node firstWaiter; /** Last node of condition queue. */ private transient Node lastWaiter; }如上是ConditionObject的属性，它里面也维持了一个链表，这个链表不同于CLH队列，这个链表只维护由于条件变量不满足被挂起的线程。我们不妨将这个队列称为条件队列。3.3.1 awaitpublic final void await() throws InterruptedException { //判断线程中断 if (Thread.interrupted()) throw new InterruptedException(); //将当前线程以尾节点的形式加入到条件队列 Node node = addConditionWaiter(); //释放锁 int savedState = fullyRelease(node); int interruptMode = 0; //休眠挂起 while (!isOnSyncQueue(node)) { LockSupport.park(this); //处理中断 if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } //醒来后重新获得锁 if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; //清理队列 if (node.nextWaiter != null) // clean up if cancelled unlinkCancelledWaiters(); //处理中断 if (interruptMode != 0) reportInterruptAfterWait(interruptMode); }//这段代码是在将当前线程封装为Node节点并以尾节点的形式加入到条件队列 private Node addConditionWaiter() { Node t = lastWaiter; //如果尾节点不为空，且状态不是CONDITION（加入到条件队列的节点状态应该都是CONDITION，不是的代表线程被取消了） if (t != null && t.waitStatus != Node.CONDITION) { //执行条件队列的清理工作（剔除无意义节点） unlinkCancelledWaiters(); t = lastWaiter; } //将当前节点以状态为CONDITION的形式加入到队列尾部 Node node = new Node(Thread.currentThread(), Node.CONDITION); //如果尾节点为null，则说明队列没东西，因此头尾节点都是当前节点，条件队列是没有傀儡节点的 if (t == null) firstWaiter = node; else //当前队列为尾节点 t.nextWaiter = node; lastWaiter = node; return node; }//清理工作，剔除整个队列中无意义的节点，剔除的方法是遍历链表 //核心是单链表的三指针算法，t指针指向当前节点，trail指针指向当前节点的前置节点，而next指针指向当前节点的后置节点 private void unlinkCancelledWaiters() { Node t = firstWaiter; //trail是当前节点的前置节点 Node trail = null; //从头开始遍历 while (t != null) { Node next = t.nextWaiter; //当前节点被取消，则从队列中删除当前节点 if (t.waitStatus != Node.CONDITION) { t.nextWaiter = null; if (trail == null) firstWaiter = next; else trail.nextWaiter = next; if (next == null) lastWaiter = trail; } else trail = t; t = next; } }//fullyRelease是AQS的方法，用于完全的释放锁（比如对于可重入锁，就得多释放几次） final int fullyRelease(Node node) { boolean failed = true; try { int savedState = getState(); if (release(savedState)) { failed = false; return savedState; } else { throw new IllegalMonitorStateException(); } } finally { if (failed) node.waitStatus = Node.CANCELLED; } }这里需要重点解释下fullyRelease()，首先我们得明白，会执行condition.await()方法的线程都是已经获得了锁的，因为Condition与Lock之间的配合关系是：lock.lock(); try{ //... condition.await(); //... }finally{ lock.unlock(); }也即必须要先获得锁，那为何又要在条件不满足而阻塞挂起来的时候释放掉锁呢？我们假设现在面包房里没面包了，一个消费线程获得锁想消费面包，但由于没有面包，因此await()挂起，此时如果不释掉锁，其他任何线程都是无法进入临界区代码执行的，包括生产线程（生产线程和消费线程是同一个锁），这样也就造成了死锁。因此如果条件不满足，就需要释放锁。同样的，如果某个时刻条件满足了，线程由于signal()被唤醒了，那是继续执行吗，还是先获得锁再执行，答案是先获得锁再执行。final boolean isOnSyncQueue(Node node) { if (node.waitStatus == Node.CONDITION || node.prev == null) return false; if (node.next != null) // If has successor, it must be on queue return true; /* * node.prev can be non-null, but not yet on queue because * the CAS to place it on queue can fail. So we have to * traverse from tail to make sure it actually made it. It * will always be near the tail in calls to this method, and * unless the CAS failed (which is unlikely), it will be * there, so we hardly ever traverse much. */ return findNodeFromTail(node); }由于我们传入的参数Node的状态就是CONDITION，因此直接返回false，那么也就代表当前线程需要挂起休眠。唤醒后，线程会执行acquireQueued(node, savedState)来重新获得锁（考虑了可重入，因此获得锁的次数与释放次数相等）。最后还顺带清理了一下条件队列，剔除了里面被取消的节点。3.3.2 signalpublic final void signal() { //判断下当前调用signal的线程是不是持有锁的线程，如果不是抛异常 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); //拿到队列的第一个节点，准备唤醒 Node first = firstWaiter; if (first != null) doSignal(first); }private void doSignal(Node first) { do { //这是在从队列中删除第一个节点 if ( (firstWaiter = first.nextWaiter) == null) lastWaiter = null; first.nextWaiter = null; //这里采用do while，如果设置失败，代表当前节点被取消了，就换成下一个节点继续 } while (!transferForSignal(first) && (first = firstWaiter) != null); }final boolean transferForSignal(Node node) { //将状态由CONDITION置为0，如果设置失败则代表当前节点已经被取消了 if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) return false; //上面其实就是让这个节点从条件队列里删除，然后重新加入到CLH队列，重新获得锁运行 //其中p是当前节点在CLH队列中的前置节点 Node p = enq(node); int ws = p.waitStatus; //如果当前节点的前置节点状态<=0（未被取消），则将其设置为-1，代表后续有需要被唤醒的节点 //否则 否则代表的是前置节点状态 >0 或者设置-1失败，此时直接唤醒当前线程（这代表CLH队列中当前节点已经是实际头节点了） if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) LockSupport.unpark(node.thread); return true; }signalAll()与doSignal()类似，只不过会尝试将所有的条件节点从条件队列中删除移到CLH队列里：public final void signalAll() { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); Node first = firstWaiter; if (first != null) doSignalAll(first); }private void doSignalAll(Node first) { lastWaiter = firstWaiter = null; do { //遍历所有的节点做transferForSignal操作 Node next = first.nextWaiter; first.nextWaiter = null; transferForSignal(first); first = next; } while (first != null); }可以看到所谓await()就是将节点加入条件队列并释放锁然后挂起。而所谓signal()就是将节点从条件队列里移除，重新加入到CLH队列获取锁。当获取锁成功后，线程被唤醒，此时我们仍然需要重新判断当前是否满足条件，也即：while (count==0){ notEmpty.await(); }这是因为假设我们生产了一个面包但用了signalAll()，此时所有消费者线程都被移到CLH队列争抢锁，第一个抢到锁的会消费掉面包，后续抢到锁的由于面包没了还得挂起，因此我们需要while的形式判断。不满足就挂起，醒来后继续判断。3.4 CountDownLatchReentrantLock是一个典型的独占锁，而CountDownLatch是共享锁的思想。独占锁和共享锁其实都可以做到线程间的同步机制。CountDownLatch核心功能也是同步多个线程。假设我们需要等待某个（或多个）条件成熟，在这个条件（或所有条件）未成熟前，所有依赖于这个条件的线程都需要阻塞，一旦条件成熟，这些挂起的线程就都可以唤醒执行。CountDownLatch翻译为闭锁。一个闭锁相当于一扇大门，在大门打开之前所有线程都被阻断，一旦大门打开所有线程都将通过，但是一旦大门打开，所有线程都通过了，那么这个闭锁的状态就失效了，门的状态也就不能变了，只能是打开状态。也就是说闭锁的状态是一次性的，它确保在闭锁打开之前所有特定的活动都需要在闭锁打开之后才能完成。//创建 CountDownLatch，下面代码的CountDownLatch均是这个创建的对象 CountDownLatch countDownLatch = new CountDownLatch(2);//线程1.创建100个线程，挂起等待两个条件成熟，两个条件均成熟后才被唤醒执行 public void test(){ for(int i = 0; i < 100; i++){ Thread thread = new Thread(()->{ System.out.println("条件未成熟，线程被挂起阻塞"); countDownLatch.await(); System.out.println("条件成熟，线程被唤醒执行"); }); thread.start(); } }//线程2. 条件1成熟 public void notify1(){ countDownLatch.countDown(); }//线程3. 条件2成熟 public void notify1(){ countDownLatch.countDown(); }上述代码中，先创建了一个需要等待两个条件的CountDownLatch。线程1创建了100个线程，这100个线程执行countDownLatch.await()，代表会一直等待，等到countDownLatch的两个条件均成熟才继续执行。线程2执行countDownLatch.countDown();，表示一个条件成熟线程3执行countDownLatch.countDown();，表示另一个也成熟。此时两个条件均成熟，挂起的100个线程均会被唤醒然后执行自己后续的代码。可以看到我们通过CountDownLatch实现了100个线程的同步，让100个线程均阻塞等待，直到两个条件成熟，一旦条件成熟，100个线程均会被唤醒（不像独占锁一个一个唤醒）。下面我们看一下CountDownLatch是如何实现await()和countDown()的。3.4.1 await//当创建一个闭锁时，传入count，这个count值会传给AQS的state值 //AQS的state值就是我们之前说的锁的状态位，比如在独占锁中我们进行lock和unlock的时候也是在修改这个state值(+1，-1) //在CountDownLatch中，count的数量就是需要等待条件的数量，只有所有条件均成熟，才能放行等待的线程。 public CountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException("count < 0"); this.sync = new Sync(count); } Sync(int count) { setState(count); } protected final void setState(int newState) { state = newState; }//调用await操作本质是调用sync.acquireSharedInterruptibly(1);sync是CountDwonLatch内部类 //当前方法是先判断是否满足获得锁(其实没有锁的概念，就是看state是否等于0，如果等于0就代表无需等待，因为没有需要等待的条件，条件均成熟了)，如果不满足就将自己挂起 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); }//判断自己是否满足获得锁，如果state==0代表等待条件全部满足 返回正值代表获取锁成功否则代表获取锁失败 protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }//走到这里必然代表获取锁失败（有未成熟的条件，需要挂起等待），所以同独占锁，将自己变为节点加入到CLH队列自旋挂起 private void doAcquireSharedInterruptibly(int arg) throws InterruptedException { //addWaiter方法与独占锁代码相同，都是将当前线程变为节点加入到CLH队列的末尾 //只不过对于独占锁，是以Node.SHARED方式加入，而同步锁是null final Node node = addWaiter(Node.SHARED); boolean failed = true; try { //与独占锁挂起相同，for循环不断尝试挂起 for (;;) { //如果当前节点是真实头节点，那么调用tryAcquireShared尝试获取锁 final Node p = node.predecessor(); if (p == head) { //tryAcquireShared会返回1或-1，上面我们已经看了它的源码，1代表获取锁成功（无等待条件），-1代表获取锁失败（有等待条件） int r = tryAcquireShared(arg); if (r >= 0) { //如果获取锁成功，将当前节点设为新的头结点并唤醒继任节点 setHeadAndPropagate(node, r); p.next = null; // help GC failed = false; return; } } //如果当前线程不是头结点或者获取锁失败，判断当前线程是否应该挂起 //这段代码也许独占锁完全相同，就是清理CLH队列的节点，如果前面节点是SIGNAL状态，那么就将自己挂起 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { //这里也是一样的，如果线程中断就将这个节点CANCELLED if (failed) cancelAcquire(node); } }//设置新的头结点 将当前节点设为头结点并且唤醒继任节点，走到这其实propagate必然等于1 private void setHeadAndPropagate(Node node, int propagate) { Node h = head; setHead(node); if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) { //获取继任节点，并判断如果是共享模式，就执行doReleaseShared唤醒继任节点。 Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } }//唤醒头结点的继任节点 //这里的逻辑有些复杂，但核心就是走到unparkSuccessor(h)这句话 private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; if (ws == Node.SIGNAL) { if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // loop to recheck cases //唤醒继任节点，这个函数我们在独占锁讲过 unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; // loop on failed CAS } if (h == head) // loop if head changed break; } }3.4.2 countDown//countDownLatch的countDown //本质就是将state状态位-1然后如果减为了0 就调用doReleaseShared，唤醒CLH队列头结点的继任节点 public final boolean releaseShared(int arg) { //尝试将当前状态位-1 如果状态位归0返回true否则返回false if (tryReleaseShared(arg)) { //尝试释放当前头结点的继任节点 doReleaseShared(); return true; } return false; }//将state状态位-1，如果state==0就返回true，否则返回false protected boolean tryReleaseShared(int releases) { for (;;) { int c = getState(); if (c == 0) return false; int nextc = c-1; //通过CAS操作将state-1 if (compareAndSetState(c, nextc)) return nextc == 0; } }现在我们就看完了CountDownLatch的源码，那么我们可以对比一下独占锁和共享锁：首先在ReentrantLock中，锁的状态位state代表当前持有锁的线程数量，但在CountDownLatch中代表需要等待的条件的数量。其次独占锁的逻辑是这样的：lock.lock(); dosomething(); lock.unlock();因此当独占锁获得锁后会先执行自己的业务代码，在执行完业务代码后再释放锁唤醒下一个线程。所以这个流程就是：获得锁->执行业务代码->释放锁唤醒下一个线程->获得锁->执行业务代码->释放锁唤醒下一个线程-> ......但共享锁完全不同，共享锁在获得锁后，会先唤醒下一个线程再执行自己的代码，也即：获得锁->唤醒下一个线程后再执行自己的业务代码->获得锁->唤醒下一个线程后再执行自己的业务代码->获得锁->唤醒下一个线程后再执行自己的业务代码-> ......这样一旦头节点获得了锁，大家的所有逻辑都是先唤醒继任节点再干自己的事，因此CLH队列内的所有阻塞线程会在很快的时间均被唤醒。这样就会出现一种一旦锁释放，所有线程都可以运行自己代码的现象，也就可以理解为所有线程共享一个锁。独占锁和共享锁最大的区别就是唤醒继任节点的时间点。3.5 CyclicBarrier想象这样一个场景：过年了，大家一起都等着吃年夜饭。现在规定饭没做好的时候大家都会等它做好再吃，如果已经做好了就可以直接吃。假设年夜饭会在六点钟做好，小孩5点就开始等了，5点半的时候女人也开始加入一起等的人里面，一直到六点，这时老人也来了，饭正好也做好，大家不用等其他人，饭做好了就直接吃。上面这个场景我们可以很容易的使用一个CountDownLatch模拟出。CountDownLatch只需要一个条件，就是年夜饭做好。每个小孩，老人或女人都是一个单独的线程，小孩在5点的时候await()，女人在5点半的时候await()，老人在6点的时候await()。CountDownLatch在6点的时候执行countDown()此时所有被等待的人均可以开始吃饭了。但在七点的时候男人回来了，男人回来后提出了一些新的规矩：男人觉得年夜饭大家应该一起开动一起吃，怎么能不等他们就直接开吃呢？因此男人规定，不管年夜饭什么时候做好，只要人凑齐了，大家就可以开始吃了。现在难办了，因为CountDownLatch只会关心等待的条件有没有成熟，但同时有多少线程在等待（人有没有齐）并不关心。此时就是CyclicBarrier大展身手的时候了，Barrier翻译栅栏或屏障，就好像前面有一个屏障，只有大家都到齐了，才能手拉手一起过去，谁走的快一点谁就在屏障点那等着。上面吃饭的场景代码如下：/** * @author coderZoe * @date 2022/6/23 13:26 */ public class NewYearDinner { private final CyclicBarrier cyclicBarrier; private final int count; public NewYearDinner(int count) { this.count = count; this.cyclicBarrier = new CyclicBarrier(count); } public void readyToEat(){ new Thread(() ->{ try { System.out.println("准备吃饭"); cyclicBarrier.await(); System.out.println("太好了，大家都准备好了，可以吃饭了"); } catch (InterruptedException | BrokenBarrierException e) { throw new RuntimeException(e); } }).start(); } //测试方法 public static void main(String[] args) throws InterruptedException { int personCount = 10; NewYearDinner newYearDinner = new NewYearDinner(personCount); for(int i = 0; i < personCount; i++){ newYearDinner.readyToEat(); Thread.sleep(1000); } } } CyclicBarrier在构建的时候需要传参count，这代表需要等待的线程数，只有一共count个线程在等待时，才代表到齐了，可以放行了。我们之前在CountDownLatch时讲过：一旦大门打开，所有线程都通过了，那么这个闭锁的状态就失效了，门的状态也就不能变了，只能是打开状态。也就是说闭锁的状态是一次性的，它确保在闭锁打开之前所有特定的活动都需要在闭锁打开之后才能完成。CyclicBarrier中有一个单词是Cyclic，翻译为循环或周期的，这代表CyclicBarrier可以循环的时候，不仅只能用一次，这也是与CountDownLatch的不同。还以上面的吃年夜饭为例：现在大家都已经开始吃年夜饭了，小孩子一般吃的快一些而男人与老人一般吃的慢一些，吃完饭后往往要放烟花，自然而然的，放烟花也是要等大家一起的，先吃完的必须等待还没吃完的，等到大家都吃完了，才能放烟花。可以看到放烟花也是一个CyclicBarrier的使用场景，我们其实可以复用之前的CyclicBarrier，其代码如下：package cn.com.coderZoe.Module10JUC.cyclicbarrier; import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CyclicBarrier; /** * @author coderZoe * @date 2022/6/23 13:26 */ public class NewYearDinner { private final CyclicBarrier cyclicBarrier; private final int count; public NewYearDinner(int count) { this.count = count; this.cyclicBarrier = new CyclicBarrier(count); } public void readyToEat(){ new Thread(() ->{ try { System.out.println("准备吃饭"); cyclicBarrier.await(); System.out.println("太好了，大家都准备好了，可以吃饭了"); } catch (InterruptedException | BrokenBarrierException e) { throw new RuntimeException(e); } }).start(); } public void readyToFireworks(){ new Thread(() ->{ try { System.out.println("准备放烟花"); cyclicBarrier.await(); System.out.println("太好了，大家都准备好了，可以放烟花了"); } catch (InterruptedException | BrokenBarrierException e) { throw new RuntimeException(e); } }).start(); } //测试函数 public static void main(String[] args) throws InterruptedException { int personCount = 10; NewYearDinner newYearDinner = new NewYearDinner(personCount); for(int i = 0; i < personCount; i++){ newYearDinner.readyToEat(); Thread.sleep(1000); } for(int i = 0; i < personCount; i++){ newYearDinner.readyToFireworks(); Thread.sleep(1000); } } }可以看到我们使用了同一个CyclicBarrier。在讲CyclicBarrier#await()实现之前，我们先来看下CyclicBarrier的部分源码实现：CyclicBarrier的属性：//独占锁 private final ReentrantLock lock = new ReentrantLock(); //条件变量 用于线程间的同步 private final Condition trip = lock.newCondition(); //需要彼此等待的线程的个数 private final int parties; //在一组任务执行完，就会执行这个runnable任务 private final Runnable barrierCommand; //代; 一组任务代表一代或者说一次CyclicBarrier的使用代表一代 private Generation generation = new Generation(); //当前还需要等待的线程数 如果为0就代表无需等待，所有人都完成了。 private int count;CyclicBarrier的构造方法//构造方法，parties代表要彼此等待的线程数，同时支持传入一个runnable方法，在最后一个线程到达屏障点后会执行这个方法，如果为空则不执行 public CyclicBarrier(int parties, Runnable barrierAction) { if (parties <= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; } public CyclicBarrier(int parties) { this(parties, null); }通过上面的部分源码和例子，我们可以总结CyclicBarrier特性如下：await()方法将挂起线程，直到同组的其它线程执行完毕才能继续await()方法有一个返回值，会返回一组内还剩多少线程未到屏障点可循环使用CyclicBarrier 的构造函数允许携带一个任务，这个任务将在0%屏障点执行，它将在await()==0后执行。3.5.1 awaitawait有几个重载方法，其本质都是会调用到dowait()方法，可以看到dowait()才是核心方法public int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } } public int await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException { return dowait(true, unit.toNanos(timeout)); }//实际执行线程阻塞和挂起的核心方法 private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException,TimeoutException { //整个dowait操作都是线程安全的，ReentrantLock将整个函数包了起来 //原因是组内的线程调用await肯定是并发调用的，为了保证对CyclicBarrier内数据的访问和修改是线程安全的，所有加独占锁。 final ReentrantLock lock = this.lock; lock.lock(); try { //当前的代或者当前组 final Generation g = generation; //异常判断 if (g.broken) throw new BrokenBarrierException(); //如果当前调用线程中断，直接中断整个这一组的CyclicBarrier if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } //因为调用dowait时必然是指到了屏障点，此时将count-1 int index = --count; //如果是0代表所有一组内的所有线程都到了屏障点 if (index == 0) { // tripped boolean ranAction = false; try { //因为count现在等于0，那么代表当前正在执行await的线程是到达屏障点的最后一个线程 //最后一个线程需要执行一开始构造方法放进来的barrierAction， //就是我们刚才说的每组任务执行完会由最后一个线程执行barrierAction，对应上面特性4 final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; //因为当前组都执行完了，所以需要唤醒其他线程，并且复位CyclicBarrier供下一次使用。 nextGeneration(); return 0; } finally { //这里是判断执行barrierAction失败了，如果执行失败也会终止当前一组的CyclicBarrier if (!ranAction) breakBarrier(); } } //会走到这代表当前await线程不是最后一个到达屏障点的线程，他就需要挂起等待。 for (;;) { try { //如果不关心超时，直接执行条件变量的await将自己挂起 if (!timed) trip.await(); //否则就使用条件变量的awaitNanos判断是否超时 else if (nanos > 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { //要是线程被中断了（或超时了，其实也是被中断），就中断掉当前barrier，即结束本组。 if (g == generation && ! g.broken) { breakBarrier(); throw ie; } else { // We're about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // "belong" to subsequent execution. Thread.currentThread().interrupt(); } } //异常处理 如果本轮被挂掉了，就抛出异常 if (g.broken) throw new BrokenBarrierException(); //会走到这代表正常被唤醒了（被最后一个线程全唤醒了,g!=generation的原因是因为最后一个线程new了一个新的Generation） //返回自己的序号就可以了 if (g != generation) return index; //如果超时就抛出超时异常并终止当前一组的CyclicBarrier if (timed && nanos <= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } }//当最后一个线程到达屏障点时会调用这个函数 private void nextGeneration() { //通知其他线程 放行大家 trip.signalAll(); //复位 count = parties; generation = new Generation(); }//中断和复位 private void breakBarrier() { generation.broken = true; count = parties; trip.signalAll(); }可以看到CyclicBarrier对于多线程的同步是通过Lock与Condition这一组合实现的。3.6 SemaphoreSemaphore也即信号量，很多同学可能在学习操作系统的时候学习过它，如果没学过也没关系。Semaphore最多的使用场景就是池化，比如我们熟知的线程池，连接池都是用到了池化技术。Semaphore 是一个计数信号量。从概念上讲，信号量维护了一个许可集。在许可可用前会阻塞每一个 acquire()的线程。每个 release() 添加一个许可，从而可能释放一个正在阻塞的获取线程。但是，Semaphore不使用实际的许可对象， 只对可用许可的号码进行计数，并采取相应的行动。 也即Semaphore是一个计数器，每有一个请求计数器会减1，每次释放计数器就会+1，在计数器大于0时允许线程通过，在计数器等于0时会阻塞线程，即使已经获得许可的线程也会阻塞，因此Semaphore是不可重入的。我们可以通过Semaphore实现一个对象池：//对象池 public class ObjectPool <T>{ public interface ObjectFactory<T>{ T makeObject(); } class Node{ T obj; Node next; } private final int capacity; private final ObjectFactory<T> factory; private final Lock lock = new ReentrantLock(); private final Semaphore semaphore; private Node head; private Node tail; public ObjectPool(int capacity, ObjectFactory<T> factory) { this.capacity = capacity; this.factory = factory; this.semaphore = new Semaphore(this.capacity); this.head = null; this.tail = null; } //从池内获取一个资源 public T getObject() throws InterruptedException { semaphore.acquire(); return getNextObject(); } //将一个资源返回给资源池 public void returnObj(T t){ returnObjToPool(t); semaphore.release(); } private T getNextObject(){ lock.lock(); try { if(head==null){ return factory.makeObject(); }else { Node ret = head; head = head.next; if(head==null){ tail=null; } ret.next = null; return ret.obj; } }finally { lock.unlock(); } } private void returnObjToPool(T t){ lock.lock(); try { Node node = new Node(); node.obj = t; if(tail==null){ head = tail = node; }else { tail.next = node; tail = node; } }finally { lock.unlock(); } } }上面的对象池逻辑比较简单，首先对象池内部维护了一个链表，链表代表了对象池，在构造方法时传入对象池的大小，并构造Semaphore。其次通过Semaphore来控制资源的获取数量，只有当前被获取的资源小于capacity，才能进入getNextObject()获取资源，对象的获取就是返回对象池的头结点。当对象返回给对象池时就是执行将节点加入对象池的尾端并执行semaphore.release()，当对象池的对象用完后再请求会被阻塞直到有新的对象被返回回来。3.6.1 信号量与独占锁想一下如果我们将Semaphore的初始化大小设为1，即只允许有一个可用的许可，如果现在有人在用，那必须等到它返回其他人才能用。这样看信号量起到了一个互斥锁的作用。这种信号量我们称为二进制信号量（二值信号量），因为只有两个状态可用和不可用。所以与锁相似，信号量也存在公平信号量和非公平信号量，对于公平信号量就是说一定会按请求的顺序获取锁而非公平信号量就是先获取如果获取不得再加入队列等待。3.6.2 信号量与条件变量信号量其实还可以作为条件变量使用，我们假设现在有两个线程A和B，A调用信号量的acquire()希望能够阻塞挂起，然后B调用信号量的release()将挂起的A唤醒，这就类似于A线程在等待条件B。在这种情况下，信号量的初始值需要赋为0，这似乎很奇怪，一个可用资源是0的信号量。其实只有这样，当A执行acquire()才会被挂起，此时信号量-1，然后B调用release()，此时信号量+1，线程A被唤醒。注：在JUC下无法通过信号量为0实现条件变量，因为JUC下的信号量在唤醒线程的时候做了是否有可用资源的判断。之所以加进来信号量与条件变量是因为笔者在阅读《操作系统导论》时书中提及的知识，笔者认为可能是不同语言下信号量的实现不同，而且信号量为0这一思路也很新奇，因此加进了笔记。3.6.3 acquire//当调用这里的时候会走到Sync(内部类)的acquireSharedInterruptibly public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } //这里的意思是说线程会优先尝试获取锁，如果获取成功(计数器大于等于0，即存在可用资源)就直接返回，否则阻塞挂起线程。 public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); //tryAcquireShared会返回当前池内可用资源，如果小于0说明池内无可用资源 if (tryAcquireShared(arg) < 0) //如果池内无可用资源，就将线程挂起，否则直接返回 doAcquireSharedInterruptibly(arg); }//对于公平锁和非公平锁其区别就在tryAcquireShared这个函数 //与ReentrantLock的相同信号量的公平锁和非公平锁也是在获取锁的一瞬间有区别，非公平锁会先尝试获取，获取成功就直接用，不成功再入队列，公平锁会先判断自己是不是头节点 //公平锁 protected int tryAcquireShared(int acquires) { for (;;) { //与Reentrant公平锁内的实现相似 //如果不是头结点(当前节点有前置节点) 直接返回-1 if (hasQueuedPredecessors()) return -1; //走到这代表当前节点有可能是头节点，具有获得锁的权力 //获得state，将state减1，如果剩余小于0则直接返回，否则将state的状态设为减去1后的值(CAS加for) int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) return remaining; } } //非公平锁 protected int tryAcquireShared(int acquires) { return nonfairTryAcquireShared(acquires); } final int nonfairTryAcquireShared(int acquires) { //直接获取，如果获取失败(剩余小于0)就返回，否则就不断获取直到通过CAS操作将state设为减1后的值成功返回 //也即与公平锁的区别是不需要考虑当前节点是否是头结点 for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) return remaining; } }上面需要解释的是for(;;)，之所以加for循环是因为如果当前remaining不小于0且CAS操作失败，那么可以继续重新执行。doAcquireSharedInterruptibly()函数源码在CountDownLatch时已经介绍过。3.6.4 release//释放资源 public void release() { sync.releaseShared(1); } //尝试释放锁，释放成功后唤醒头结点的继任节点 public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { //这里说过很多次了，共享模式下的继任节点唤醒 doReleaseShared(); return true; } return false; }//就是简单的将sate+1然后通过CAS操作设置state的值 protected final boolean tryReleaseShared(int releases) { //为避免CAS操作失败，for循环不断尝试 for (;;) { int current = getState(); int next = current + releases; if (next < current) // overflow throw new Error("Maximum permit count exceeded"); if (compareAndSetState(current, next)) return true; } }doReleaseShared在CountDownLatch中讲过。3.6.5 一些总结Semphore的源码已经看完了，简单来讲还是内部维持的一个state，每次请求资源的时候就执行state-1操作，当state-1小于0就挂起线程，当state-1大于等于0就将state设为sate-1，然后返回。当资源释放的时候就执行state+1操作并唤醒继任节点。还回到锁本身讨论，这些锁或锁的应用类本质都是维护了state值，这个值得是线程安全的，所以通过CAS操作这个值。对于ReentrantLock，这个state代表的是持有线程的锁的个数，如果state大于0并且不是正在拥有锁的线程就挂起，当unlock时state-1，并唤醒阻塞线程（如果state==0才唤醒）。对于CountDownLatch和CyclicBarrier这个state代表的是剩余条件个数，state的值大小是初始化时指定的。每有一个条件完成state就-1，当减为0代表条件成熟唤醒所有阻塞线程。对于Semaphore这个state代表的是资源可用数，也是在初始化时指定。每次资源请求时state会-1，当资源减为0还在请求时就会被挂起等待，而每次资源返回时state就会+1，并且唤醒等待的节点。对于线程的唤醒，ReentrantLock、CountDownLatch、CyclicBarrier和Semaphore本质都是相同的，就是一旦满足条件就唤醒CLH队列的首个线程，不同之处在于唤醒的时机。ReentrantLock是在执行完临界代码，然后unlock的时候才唤醒，即临界代码执行完才会释放锁，符合独占的思想。而对于CountDownLatch或Semaphore，会在线程一旦获得锁后也唤醒继任节点，符合共享的思想（一人得到锁，通知其他人也试着去得到）。举个例子，如果是ReentrantLock，一个线程获得锁后会先执行自己的代码，执行完后会手动释放锁，释放锁时唤醒继任节点，继任节点也重复上述操作，每个时间只有一个线程拥有锁。而对于CountDownLatch，一个线程获得锁后代表条件成熟，他会先唤醒其他线程再执行自己的操作，当继任线程被唤醒，也会尝试获得锁，自然也可以得到锁，那么一个传一个，CLH队列内的所有节点都会被唤醒，大家醒来都是先唤醒别人再干自己的事，所以CountDownLatch是一醒都醒。Semaphore有一点不同，他也是共享锁，一个线程获得锁后会先唤醒继任节点再做其他事，但不同于CountDownLatch所有继任节点都可以获得锁，Semaphore的继任节点可能不一定能获得锁，这时这个不能获得锁的节点的后续节点均不能被唤醒了，即Semaphore不能做到一醒都醒，只能是一醒，在一定范围内的节点都醒（醒来的节点数等于可获取资源数，非公平锁不算）。3.7 ReadWriteLock读写锁是一种使用非常频繁的锁，比如MySQL事务的串行化隔离级别就是通过读写锁实现的。读写锁的思想很简单，读锁是一个共享锁，也即读操作可以多线程同时进行。写锁是一个独占锁，写操作需要串行的进行。JUC中读写锁的接口如下：public interface ReadWriteLock { Lock readLock(); Lock writeLock(); }同时其具体实现为ReentrantReadWriteLock，我们以一个例子来说明读写锁的使用方法：public class SimpleConcurrentMap<K,V> implements Map<K,V> { private final ReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock readLock = lock.readLock(); private final Lock writeLock = lock.writeLock(); private final Map<K,V> map; public SimpleConcurrentMap(Map<K, V> map) { this.map = map; } @Override public int size() { readLock.lock(); try { return map.size(); }finally { readLock.unlock(); } } @Override public boolean isEmpty() { readLock.lock(); try { return map.isEmpty(); }finally { readLock.unlock(); } } @Override public boolean containsKey(Object key) { readLock.lock(); try { return map.containsKey(key); }finally { readLock.unlock(); } } @Override public boolean containsValue(Object value) { readLock.lock(); try { return map.containsValue(value); }finally { readLock.unlock(); } } @Override public V get(Object key) { readLock.lock(); try { return map.get(key); }finally { readLock.unlock(); } } @Override public V put(K key, V value) { writeLock.lock(); try { return map.put(key,value); }finally { writeLock.unlock(); } } @Override public V remove(Object key) { writeLock.lock(); try { return map.remove(key); }finally { writeLock.unlock(); } } @Override public void putAll(Map<? extends K, ? extends V> m) { writeLock.lock(); try { map.putAll(m); }finally { writeLock.unlock(); } } @Override public void clear() { writeLock.lock(); try { map.clear(); }finally { writeLock.unlock(); } } @Override public Set<K> keySet() { readLock.lock(); try { return map.keySet(); }finally { readLock.unlock(); } } @Override public Collection<V> values() { readLock.lock(); try { return map.values(); }finally { readLock.unlock(); } } @Override public Set<Entry<K, V>> entrySet() { readLock.lock(); try { return map.entrySet(); }finally { readLock.unlock(); } } }利用读写锁我们实现了一个简单的并发Map，可以看到这里的使用很简单，就是要读的时候加读锁，要写的时候加写锁。ReadWriteLock需要严格区分读写操作，如果读操作使用了写入锁，那么降低读操作的吞吐量，如果写操作使用了读取锁，那么就可能发生数据错误。接下来我们看下读写锁的实现：首先读写锁内部属性如下：//读锁 private final ReentrantReadWriteLock.ReadLock readerLock; //写锁 private final ReentrantReadWriteLock.WriteLock writerLock; //锁 final Sync sync;ReadLock和WriteLock是ReentrantReadWriteLock的两个内部类，我们在执行lock.readLock()和lock.writeLock()的时候就是在获取readerLock和writerLock：public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; } public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }Sync也是ReentrantReadWriteLock的内部类，其底下有两个具体实现类FairSync和NonfairSync（这与ReentrantLock和Semaphore等相似）。读锁和写锁内部都有一个Sync属性：public static class ReadLock implements Lock, java.io.Serializable { private final Sync sync; protected ReadLock(ReentrantReadWriteLock lock) { sync = lock.sync; } } public static class WriteLock implements Lock, java.io.Serializable { private final Sync sync; protected WriteLock(ReentrantReadWriteLock lock) { sync = lock.sync; } }而Sync是ReentrantReadWriteLock的内部抽象类，其有两个实现类是公平锁和非公平锁也是ReentrantReadWriteLock的内部类（这与ReentrantLock一模一样）。Sync继承自AQS，即从这里我们可以看出，Sync（及其实现类）才是那个实际能做锁操作的对象。ReentrantReadWriteLock的构造方法如下，默认构造方法是非公平锁（与ReentrantLock一模一样）public ReentrantReadWriteLock() { this(false); } public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); }这里有个非常关键的信息，在构造ReadLock和WriteLock时，我们传入的是同一个对象(this)，那么也就代表writerLock和readerLock对象持有的sync属性是同一个对象，我们之前说过sync才是那个根本的锁，既然写锁是独占锁，读锁是共享锁，那为什么可以持有同一个锁对象呢？也即为什么读写锁是同一把锁呢？之前的学习中，我们知道锁判断的本质就是AQS里的state标志位，state标志位标识了当前线程使用锁的情况，但现在我们将一个锁劈成了读锁和写锁两个锁来用，读线程和写线程肯定有不同的判断逻辑，那我们要如何记录呢？ReentrantReadWriteLock的实现方式是将state拆为高16位字节和低16位字节（state是个32字节的int变量），高16位用来描述读锁，低16位用来描述写锁的情况，我们知道16位能表示的最大整数是65535。所以无论是读锁还是写锁，能描述的最大持有锁的线程只能是65535个（其实写锁影响不大，因为写锁是独占锁，除非不断的重入）。了解了这个之后，我们再来看读写锁的加锁和锁释放源码：3.7.1 writeLock.lock()//写锁lock的时候，其会调用到AQS的acquire，本质就是一个独占锁或者说使用锁的独占模式 public void lock() { sync.acquire(1); } //通过独占的形式先尝试获得锁，获得不了就排队等待 //这段代码与ReentrantLock的lock相同，只是tryAcquire的实现有些许区别 public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }//写锁的tryAcquire(或者理解为读写锁在独占模式下的tryAcquire) protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); //这里是做了位操作，获得C的低16位 int w = exclusiveCount(c); //c是由读锁和写锁同时构成，c!=0有三种情况： //1. 读锁不为0，当前有在读的线程 //2. 写锁不为0，当前有在写的线程 //3. 读写锁均不为0，当前有正在读和写的线程(肯定是同一个线程) if (c != 0) { //如果写锁为0，那么读锁肯定不为0，此时因为有在读的线程，所以无法写入 //这里我们可以看到锁是不支持升级的，也即当有线程在读取的时候是不能写入的。不能从读取锁升级为写入锁(无论正在读取的线程是不是当前线程） //或者 首先会走到或者代表w!=0，也即当前有线程在写入，如果当前写入线程不是自己，也返回失败 if (w == 0 || current != getExclusiveOwnerThread()) return false; //走到这里代表w!=0，同时当前在写入的线程是自己 //这里是对可重入的判断，我们说过，16位最多表示65535，因此这是在判断独占锁可重入次数大于65535的错误 if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); //走到这里代表着： //1.没有读线程只有写线程，且写线程是自己 //2.可重入的计数器没超过65535 //其实走到这里就是重入，而且一定是重入，因为w!=0 //更新c的值 setState(c + acquires); return true; } //走到这代表c=0 因为c!=0的情况都判断完了。也即当前读锁和写锁都没人用 //判断当前线程是否需要阻塞，如果不需要阻塞就直接返回false代表获取锁失败 //如果不需要阻塞，将c值更新 如果更新失败返回false代表锁获取失败 //否则代表锁获取成功，将当前线程设为独占线程，返回成功 //这里可能有人有疑问，c都已经等于0了，那直接获得锁不就行了，咋还需要判断是不是需要挂起 //原因很简单，有可能其他线程一起进来竞争写锁，你刚才读到的c=0不代表现在还是 if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) return false; setExclusiveOwnerThread(current); return true; }其中writerShouldBlock()的判断就是公平锁 与非公平锁的区别：//公平锁会执行hasQueuedPredecessors，如果当前节点是真实头结点就不需要挂起，否则就得挂起 final boolean writerShouldBlock() { return hasQueuedPredecessors(); } //非公平锁获取锁的时候不需要考虑顺序，直接返回false直接获取就可以了。 final boolean writerShouldBlock() { return false; }可以看到公平锁还是得先判断当前节点是队列头节点才允许获取锁，非公平锁直接获取。acquireQueued()与addWaiter()、selfInterrupt()上面均讲过（在ReentrantLock一节）。3.7.2 writeLock.unlock()//写锁释放的时候会调用AQS的release函数 与独占锁相同 public void unlock() { sync.release(1); } public final boolean release(int arg) { //尝试释放锁，如果释放成功就判断当前节点是否有后继节点需要唤醒，如果需要唤醒就唤醒后继节点 if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }//尝试释放锁 protected final boolean tryRelease(int releases) { //如果释放锁的线程不是当前独占的线程就抛出异常 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); //将c值减1 由于写锁是低16字节，因此直接-1即可 int nextc = getState() - releases; //通过位操作判断当前写锁是否等于0，等于0就代表当前线程执行完了，可以换下一个线程了 boolean free = exclusiveCount(nextc) == 0; //如果写锁state==0代表当前写锁已经没有人占用，将独占的字段清空 if (free) setExclusiveOwnerThread(null); //同时更新state字段 setState(nextc); //返回当前锁是否释放 return free; }后续锁释放流程与独占锁相同，不再赘述。3.7.3 readLock.lock()//调用共享锁的lock public void lock() { sync.acquireShared(1); } public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }//尝试获取锁 protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); int c = getState(); //如果写锁不等于0并且写锁的使用线程不是当前线程，那么直接返回失败 //如果是当前线程则代表写入的线程也可以同时执行获得读锁读取 //但是读取的时候是不能写入的 if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; //会走到这代表可以获得读锁 要么是写锁为空要么是写锁的独占线程就是当前线程 //获得读锁的值 int r = sharedCount(c); //如果读锁不需要挂起并且当前读锁没超过65535并且更新读锁的数据成功就获得锁 //否则执行fullTryAcquireShared操作，不断循环获得锁 if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { //关于if里面的东西我们下面再单独说 if (r == 0) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) readHolds.set(rh); rh.count++; } return 1; } //fullTryAcquireShared函数我们下面分析 return fullTryAcquireShared(current); }通过之前的学习我们基本知道锁的状态位state其实就是一个计数器，在获得锁的时候计数器+1，释放锁的时候计数器-1。共享锁不同于独占锁，独占锁在获取锁的时候，会有个exclusiveOwnerThread变量标识当前持有锁的是哪个线程，这样当某个线程在执行lock.unlock()时会先判断它是不是真的持有锁的那个线程，如果不是就抛出异常。但是共享锁不同，共享锁会有很多线程在持有锁，如果此时某个线程执行lock.unlock()该如何记录这个线程是不是真的曾持有共享锁，以及如何修改计数器的值呢？因此我们需要一个信息，这个信息是记录当前线程是不是真的持有共享锁以及如果持有共享锁则持有多少个（可重入下每次重入都会+1），上面if语句的作用就是做这个的，要理解if语句内的内容，需要先看下Sync内部的属性//当前线程持有的读锁的数量（可重入） private transient ThreadLocalHoldCounter readHolds; //保存最后一个获取读锁线程持有的读锁的数量（可重入）缓存的作用 private transient HoldCounter cachedHoldCounter; //第一个获得读锁的线程（第一个获得读锁的线程） private transient Thread firstReader = null; //第一个获得读锁的线程持有的读锁的数量（可重入） private transient int firstReaderHoldCount;其中ThreadLocalHoldCounter如下：static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> { public HoldCounter initialValue() { return new HoldCounter(); } }可以看到ThreadLocalHoldCounter继承自ThreadLocal，而HoldCounter类如下：static final class HoldCounter { //持有的读锁数 int count = 0; final long tid = getThreadId(Thread.currentThread()); }HoldCounter内的count是一个计数器，计的是持有读锁的数量。ThreadLocalHoldCounter是HoldCounter和ThreadLocal的结合体，因此指的就是当前线程持有读锁的数量，知道ThreadLocal的同学可能会明白，ThreadLocal是线程安全的，ThreadLocal作用域是线程，这样每个线程都持有一份自己的HoldCounter（对ThreadLocal不了解的推荐一篇文章正确理解Thread Local的原理与适用场景）。那么Sync内的readHolds属性就很好理解了，readHolds就是当前线程持有读锁的数量，每次加锁成功的时候count就会+1，释放锁的时候count就会-1。那么cachedHoldCounter、firstReader和firstReaderHoldCount是干什么的呢？其实这些都是缓存，只有一个作用就是提升性能。这时我们就得问一下，提升了什么性能？怎么提升性能的？刚才我们说到readHolds是当前线程持有的锁，但是ThreadLocal的底层实现是Map，也即每个线程通过readHolds拿自己的HoldCounter是通过Map的形式获取的，这样性能可能有点低。与此同时，读锁的使用有一个比较普遍的场景：读锁的获取往往会紧跟着该读锁的释放。换句话说，读操作会很快很快，往往短时间发生的都是同一个线程的获取锁和释放锁操作。再换句话说，执行 unlock 的线程往往就是刚刚最后一次执行 lock 的线程，中间可能没有其他线程进行 lock。cachedHoldCounter保存最后一个获取读锁的线程的HoldCounter，什么是最后一个获取读锁的线程？其实就是最新获得读锁的线程，也即cachedHoldCounter记录的是刚刚获取读锁的那个线程。上面说了，锁的获取和释放往往是紧接着发生的，既然锁获取的时候利用cachedHoldCounter记录了获取线程的HoldCounter，那释放的时候直接利用cachedHoldCounter来做count--不就好了，不需要通过ThreadLocal再从Map中获取一遍，而这就提升了性能。firstReader和firstReaderHoldCount的作用与cachedHoldCounter相同，只不过firstReader和firstReaderHoldCount记录的是第一个获取读锁的节点的信息，作用也是相同的，就是缓存线程的count信息。cachedHoldCounter是最后一个获取读锁的线程的HoldCount信息，而firstReader和firstReaderHoldCount是第一个获得读锁的线程的信息。也即Sync其实对这两个节点做了一些特殊处理，用一块内存单独缓存了这两个节点的HoldCount信息，提升了性能。但是需要注意的是cachedHoldCounter和firstReader、firstReaderHoldCount是会变化的，比如当前获得锁的线程不是上一个线程，cachedHoldCounter就得更新为这个线程的信息。如果获得锁的第一个线程释放了锁，firstReader、firstReaderHoldCount也要跟着更新。了解了上面这些信息，我们再来看之前的那段if代码：//会走到这代表获取读锁成功，既然获取读锁成功就得更新自己线程的count值 //r==0代表当前没有在读的线程，那就代表当前线程是第一个获得读锁的线程 if (r == 0) { //因此将当前线程赋给firstReader并将firstReaderHoldCount置为1 firstReader = current; firstReaderHoldCount = 1; // 否则 r!=0 代表当前有线程正在读，如果自己是firstReader，也即走到这代表第一个获得读锁线程的重入 } else if (firstReader == current) { //那直接更新firstReaderHoldCount值即可 firstReaderHoldCount++; //否则 走到这代表 r!=0并且自己也不是第一个获得读锁的线程 } else { HoldCounter rh = cachedHoldCounter; //那就判断下自己是不是最后一个获得读锁的线程呢？也即cachedHoldCounter内缓存的count是不是自己线程的 //如果不是的话，就通过readHolds从ThreadLocal的Map中取自己的HoldCount //取完后要重新将cachedHoldCounter设为自己的HoldCount，因为自己现在是最后一个获得锁的线程了。 if (rh == null || rh.tid != getThreadId(current)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) readHolds.set(rh); //更新当前线程的count值 rh.count++; }我们再来看下读锁下公平锁和非公平锁的区别：同样，公平锁和非公平锁的不同也就是readShouldBlock的实现：//公平锁会执行hasQueuedPredecessors，如果当前节点是真实头结点就不需要挂起，否则就得挂起 final boolean readerShouldBlock() { return hasQueuedPredecessors(); } //非公平锁会调用AQS的apparentlyFirstQueuedIsExclusive final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); } final boolean apparentlyFirstQueuedIsExclusive() { Node h, s; return (h = head) != null && (s = h.next) != null && !s.isShared() && s.thread != null; }公平锁的实现比较简单，与写锁的实现一致，都是走hasQueuedPredecessors()函数，判断当前线程节点是头节点后才允许获取锁。非公平锁会相对复杂，在理解非公平锁代码前我们先说下如果只是像写锁的非公平锁一样返回false会发生什么：之前我们讲过读写锁虽然表面上是两个锁，但实际上是一个锁，锁的状态位state被拆成了高低16位使用。但是锁内部的CLH队列可没有拆过，也即因为获得不了读锁或获得不了写锁的线程可都在同一个CLH队列里面。假设现在有这样一种情况：当前读写锁中，线程1和线程2都获得了读锁在进行读操作，此时线程3想获得写锁，由于读写锁不支持锁升级，那么申请写锁的线程就会进入CLH队列挂起等待。此时线程4想要申请读锁，那要如何处理呢？该不该允许线程4获得读锁然后执行读操作呢？如果readerShouldBlock()直接返回false，那么代表线程4可以获得读锁，因此现在就变成了线程1，2，4都获得了读锁执行读操作，线程3由于读锁未完全释放因此还在CLH队列内等待。但是如果线程4之后又来了n多个线程都获取读锁，那么他们都可以直接获得锁，这样线程3还是无法满足获得写锁的条件，也即后来的读锁插队到了之前的写锁前面，优先获得了读锁，这会造成锁饥饿。因此为避免上述的情况，非公平锁读锁的获取必须要先判断当前CLH队列内是否有立马可获得写锁的线程。注意是立马可获得写锁的线程，不是CLH队列内是否有要获得写锁的线程，什么是立马可获得，就是当前头节点的继任节点，也即次头节点(看来锁的设计者还是考虑了性能问题，在锁饥饿与性能之间做了一个中间方案)。因此代码final boolean apparentlyFirstQueuedIsExclusive() { Node h, s; return (h = head) != null && (s = h.next) != null && !s.isShared() && s.thread != null; }其实就是在判断当前头节点的继任节点是不是一个获取写锁的线程，如果是就让步，让人家先得到锁，如果不是，那不好意思，我先获得读锁。下面我们再来谈下fullTryAcquireShared()函数，希望你还没有忘，如果忘了可以看下tryAcquireShared()的最后一行。在分析fullTryAcquireShared()前我们先分析下什么情况下会执行fullTryAcquireShared()：当前线程具有获得读锁的权限（没有写线程或者写线程就是自己线程），但是获得读锁失败了，失败的原因通过if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) 可以分析出原因有三：执行readerShouldBlock返回true，也即当前读线程需要挂起不能获得读锁，readerShouldBlock的实现我们在公平锁和非公平锁已经讲到了当前读锁的计数值已经超过65535了执行CAS操作修改读锁的计数失败这时我们再来看下fullTryAcquireShared()的源码：final int fullTryAcquireShared(Thread current) { HoldCounter rh = null; //for 循环不断尝试 for (;;) { int c = getState(); //如果有写线程 if (exclusiveCount(c) != 0) { //并且写线程不是自己线程，那就返回失败，不能获得锁 if (getExclusiveOwnerThread() != current) return -1; //else if分支的理解见下面的注1（因为要写的太多，这里写不下） //建议先看注1再看下面的代码 } else if (readerShouldBlock()) { //如果可重入的是第一个获得读锁的线程，那重入时可以继续获得读锁，执行下面30行的CAS操作 if (firstReader == current) { } else { //下面的逻辑就比较简单，就是尝试更新cachedHoldCounter的值 if (rh == null) { rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) { rh = readHolds.get(); //如果count==0其实就是代表当前线程没有被readHolds记录过，也就代表当前线程之前没有获得过锁 //也就是说，当前线程不是重入线程，我们在注1讲了，else if分支只处理重入的线程 //因此这种情况下的线程没有记录的必要，从readHolds中删除 if (rh.count == 0) readHolds.remove(); } } //同理，count==0代表不是可重入线程，既然不是可重入又shouldBlock，那么就乖乖排队吧 if (rh.count == 0) return -1; } } //对上面else if分支的补充，其实上面else if分支就是为了确保读锁重入操作能成功，而不是被塞到阻塞队列中等待 //处理获得读锁的数量溢出情况 if (sharedCount(c) == MAX_COUNT) throw new Error("Maximum lock count exceeded"); //能走到这代表读锁数量没有溢出并且当前线程是可重入线程，那么下面就是对可重入线程对锁的获取 if (compareAndSetState(c, c + SHARED_UNIT)) { //走到这代表CAS操作成功，也即代表获取锁成功 //下面就是修改线程的HoldCount中的count值，内容与tryAcquireShared相同，不再赘述 //这里主要提醒的一点是，如果CAS不成功，因为fullTryAcquireShared是for(;;)的形式，因此会不断的尝试 if (sharedCount(c) == 0) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { if (rh == null) rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) rh = readHolds.get(); else if (rh.count == 0) readHolds.set(rh); rh.count++; cachedHoldCounter = rh; } //获得锁后返回1 return 1; } } }注1：else if (readerShouldBlock()) { //... }会走到上面那里代表没有写线程 exclusiveCount(c) == 0，但如果此时readerShouldBlock()返回true则代表当前获取读锁的线程应该阻塞。既然没有写线程那么非公平锁下readerShouldBlock()一定返回的是false，因此要想readerShouldBlock()返回true，必然是公平锁返回的结果。公平锁会返回true，因为当前CLH队列内有在排队的线程，当前线程不是CLH的头节点。但是这样就会有个问题：如果我现在想获得读锁的线程是一个重入线程，就是我之前获得过读锁了，我现在还想获得读锁，你不能因为我不是CLH队列的头节点就不让我获得锁，因此上述else if分支核心就是处理不是CLH队列的头节点但是之前获得过读锁，现在是可重入获取的情况。3.7.4 readLock.unlock()//读锁的释放，会调用AQS的releaseShared public void unlock() { sync.releaseShared(1); }//AQS中的共享锁释放 public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }//锁释放的逻辑比较简单，就是先修改线程对应的HoldCount的count值，再修改锁状态位state的值 protected final boolean tryReleaseShared(int unused) { Thread current = Thread.currentThread(); //如果当前线程是第一个获得读锁的线程，就使用缓存的firstReaderHoldCount来修改count值 if (firstReader == current) { //如果firstReaderHoldCount等于1，再减1就是0了，也就代表当前线程释放了读锁，那么就释放firstReader if (firstReaderHoldCount == 1) firstReader = null; else //否则直接-1就可以了 firstReaderHoldCount--; } else { //这里代表当前线程不是第一个获得锁的线程，此时就需要判断cachedHoldCounter有没有命中 HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) //到这代表cachedHoldCounter没有命中，此时就需要从readHolds中取HoldCount rh = readHolds.get(); int count = rh.count; //这里是对count的校验，如果count<=1就代表当前线程不再持有锁，那么就从readHolds将当前线程对应的HoldCounter删除 if (count <= 1) { readHolds.remove(); //如果count小于等于0，则代表释放锁的线程其实并不持有锁，这时就属于一个违法的锁释放，此时需要抛出异常 if (count <= 0) throw unmatchedUnlockException(); } //正常情况下对HoldCount做--操作 --rh.count; } //CAS操作，修改锁的状态位的值 //加for(;;)避免CAS失败，多次重试 for (;;) { int c = getState(); int nextc = c - SHARED_UNIT; if (compareAndSetState(c, nextc)) return nextc == 0; } }doReleaseShared()内容在CountDownLatch相同。