CountDownLatch
和CyclicBarrier
两个类,两者有什么区别,怎样使用呢CountDownLatch可以使一个获多个线程等待其他线程各自执行完毕后再执行。
CountDownLatch 定义了一个计数器,和一个阻塞队列, 当计数器的值递减为0之前,阻塞队列里面的线程处于挂起状态,当计数器递减到0时会唤醒阻塞队列所有线程,这里的计数器是一个标志,可以表示一个任务一个线程,也可以表示一个倒计时器,CountDownLatch可以解决那些一个或者多个线程在执行之前必须依赖于某些必要的前提业务先执行的场景。
CyclicBarrier可以使一定数量的线程反复地在“栅栏”位置处汇集。
当线程到达“栅栏”位置时将调用await方法,这个方法将阻塞直到所有线程都到达“栅栏”位置。如果所有线程都到达“栅栏”位置,那么“栅栏”将打开,此时所有的线程都将被释放,而“栅栏”将被重置以便下次使用。
可以通过两个简单的demo说明两者用法上的区别,首先是CyclicBarrier的例子:
1 | CyclicBarrier cb = new CyclicBarrier(3, () -> { |
可以看到,CyclicBarrier的await()
方法执行后,会阻塞住当前线程,底层使用了ReentrantLock::lock()
方法加锁,在所有线程都调用了await()
使得CyclicBarrier计数器到达0之后,则会在当前线程中回调行定义时的代码块(也可不定义),参考如下代码片段
1 | if (index == 0) { // tripped |
最后回到调用await()
的地方继续往下执行。
然后是CountDownLatch,参考下面的例子:
1 | CountDownLatch cd = new CountDownLatch(3); |
可以看到,CountDownLatch和CyclicBarrier使用上还是有一定的区别,在调用countDown()
之后并不会使当前线程阻塞,只是给内部的计时器递减1。
首先看下await()
方法
1 | public void await() throws InterruptedException { |
这里会阻塞住线程。
然后是countDown()
方法
1 | public void countDown() { |
这里sync是CountDownLatch内部类Sync的对象,而Sync又是java中AbstractQueuedSynchronizer(AQS)的派生类,所以可以看出CountDownLatch通过AQS实现。接下来继续看Sync中tryReleaseShared(int)
的实现
1 | protected boolean tryReleaseShared(int releases) { |
可以看到,这里使用CAS操作更新标记位,这里的标记位就是上面的计数器,当标记位为0后就会将阻塞的线程放行。
CountdownLatch适用于所有线程通过某一点后通知方法,而CyclicBarrier则适合让所有线程在同一点同时执行
CountdownLatch利用继承AQS的共享锁来进行线程的通知,利用CAS来进行,而CyclicBarrier则利用ReentrantLock的Condition来阻塞和通知线程
如果你还不熟悉alertmanager,请先参阅alertmanager
1 | # How long to initially wait to send a notification for a group |
首先假设alertmanager配置如下:
1 | route: |
下图说明了当按照上面配置时,alertmanager的通知时机
Alertmanager 在收到一条新的告警之后,会等待 group_wait 时间,对这条新的告警做一些分组、更新、静默的操作。当第一条告警经过 group_wait 时间之后,Alertmanager 会每隔 group_interval 时间检查一次这条告警,判断是否需要对这条告警进行一些操作,当 Alertmanager 经过 n 次 group_interval 的检查后,n*group_interval 恰好大于 repeat_interval 的时候,Alertmanager 才会将这条告警再次发送给对应的 receiver。
在该组的alert第一次被发送后,该组会进入睡眠/唤醒周期,睡眠周期将持续group_interval时间,在睡眠状态下该group不会进行任何发送告警的操作(但会插入/更新(根据fingerprint)group中的内容),睡眠结束后进入唤醒状态,然后检查是否需要发送新的alert或者重复已发送的alert(resolved类型的alert在发送完后会从group中剔除)。这就是group_interval的作用。
聚合组在每次唤醒才会检查上一次发送alert是否已经超过repeat_interval时间,如果超过则再次发送该告警。因此repeat_interval并不代表告警的实际重复间隔,因为在第一次发送告警的repeat_interval时间后,聚合组可能还处在睡眠状态,所以实际的告警间隔应该大于repeat_interval且小于repeat_interval+group_interval。因此实际生产中group_interval值不可设得太大。
理想情况下,我们总是期望每个alert都有对应的resolved, 每个resolved也能找到每个对应的alert, 但是有时会有例外情况
为什么有些resolved alert没有对应的firing alert,因为这些firing alert发送给alertmanager时其所在的group恰好处在睡眠状态下,而其对应的resolved消息也在同一睡眠周期内被发送给alertmanager,接收到resolved消息后,group将其对应的firing消息覆盖,因此在唤醒时就只接收到了resolved消息。
同理,为什么有些的firing alert没有对应的resolved alert呢?假设该firing消息发生在第n个睡眠周期,而在第n+1个睡眠周期内,该alert发生了resolved-firing-resolved…这样的状态变化,则其对应的resolved消息被n+1周期内的第二个resolved消息覆盖,因此表现为该firing alert没有对应的resolved消息。
为什么有些resolved消息接收到了多条?这个问题又涉及到prometheus rule组件的一个特性,当一个alert由firing变成resolved后,该resolved alert不会只发送给alertmanager一次,而是会先保存在内存中15分钟,并且重复多次发送给alertmanager,参看如下代码段
1 | // resolvedRetention is the duration for which a resolved alert instance |
发送多条resolved的情况为:在第n个睡眠周期内,alertmanager接收到第一条resolved alert并将其更新进group,紧接着在唤醒时发送该group并将resolved alert从group中剔除。但在第n+1个睡眠周期内,prometheus仍然在向alertmanager发送该resolved alert,因此下次唤醒时发送的group中又带有这条resolved alert。
这个容易理解,如上所述,alertmanager发送消息的单位是group,在该group被发送的下一个睡眠周期中,又有新的alert被insert到该group中,因此下一次唤醒时又发送了一次该group,表现为同一条firing alert短时间内发送了多次。
如果需要严格的每20分钟发送一次告警,则可参考如下配置,每次group_interval唤醒后总会通知一次
1 | group_wait: "5s" |
可通过模拟告警发送方(如Prometheus)和接收方(receiver)来观察alertmanager配置后的实际发送情况
发送方
1 | import cn.hutool.http.HttpUtil |
接收方
1 | import cn.hutool.core.date.DateUtil |
配置好之后,reload alertmanager,
然后启动接收程序,最后启动发送程序,发送10个告警过后暂停一段时间,然后再发送一个告警,观察日志输出情况
本文暂未考虑alertmanager集群的情况,仅考虑单个alertmanager实例
]]>Prometheus是一个开源监控解决方案,用于收集和聚合指标作为时间序列数据。是继Kubernetes之后第二个CNCF托管项目
普罗米修斯的主要特点是:
指标是外行术语中的数值测量。术语时间序列是指随时间变化的记录。用户想要测量的内容因应用程序而异。对于 Web 服务器来说,它可能是请求时间;对于数据库,它可以是活动连接或活动查询的数量等。
指标在理解应用程序为何以某种方式运行方面发挥着重要作用。假设您正在运行一个 Web 应用程序并发现它很慢。要了解您的应用程序发生了什么,您将需要一些信息。例如,当请求数量较多时,应用程序可能会变慢。如果您有请求计数指标,则可以确定原因并增加处理负载的服务器数量。
下面将会介绍常见开发语言如果集成到Prometheus监控体系中。
在Java世界里,所有应用事实上可以简单的划分为两类:Spring应用和非Spring应用。Spring已经成为了事实上的Jakarta EE(Java EE)标准,所以本节会分别介绍Spring应用和非Spring应用如何集成Prometheus指标。
首先需要说明一下就是Micrometer,Micrometer是一个在JVM-Based应用中的一个供应商中立的应用可观测性门面(facade),类似于slf4j。Micrometer支持常见的可观测性系统,包括Prometheus。
Registry
每个应用都有一个MeterRegistry对象。是所有指标的注册表。
CompositeMeterRegistry
通过CompositeMeterRegistry,可以同时添加多个注册表,允许发布到多个监控系统
globalRegistry
一个静态全局注册表
Meter
监控中不同类型的指标,包括Timer
、Counter
、Gauge
、DistributionSummary
、LongTaskTimer
、FunctionCounter
、FunctionTimer
和TimeGauge
称作Meter。
由于不同监控系统有不同的指标名约定,Micrometer建议使用小数点分割,然后会转换为目标监控系统推荐的命名约定,请看下面的例子:
1 | registry.timer("http.server.requests"); |
在不同的监控系统中,会转换为推荐的名称:
http_server_requests_duration_seconds
httpServerRequests
http.server.requests
http_server_requests
首先创建一个SpringBoot项目,然后添加依赖
1 | dependencies { |
接下来编辑application.yaml
,添加下面的配置:
1 | spring: |
首先,定义了应用的名称为JavaMetricsDemo
,然后在management中开启prometheus端点暴露,接下来通过mertris配置给所有指标添加了一个自定义tagapplication=JavaMetricsDemo
,方便区分
然后直接启动应用,访问http://localhost:8080/actuator/prometheus
,即可看到默认情况下会暴露一些常见指标,jvm,tomcat,logback等。
在实际开发中,自带的指标肯定是不够用的,需要手动添加一些业务、接口相关指标,参考下面的代码:
1 |
|
我们创建了一个DemoController,然后定义了一个返回字符串的接口,注意我们在方法上面添加了**@Timed**注解,然后重新启动应用,观察指标变化
1 | # HELP http_server_requests_seconds |
由此我们可以知道,通过添加**@Timed**,可以获取接口响应时间相关指标,包含最大值,请求次数等。通过上述指标,可能会对服务健康提供一定帮助,也可以通过指标的tag精确到每个不同的接口请求。
接下来继续改造该类
1 |
|
通过IOC容器注入一个MeterRegistry,这就是上文说的每个应用的Registry对象,我们一般不需要手动创建,直接注入使用即可。
然后我们在init段中声明一个Counter类型的指标,这个过程建议单独放到一起,在应用启动时候注册到全局的Registry即可,然后我们获取一个叫foo.errors
的Counter,用来统计错误次数,每当程序发生异常,就将次数加一。
再次启动应用,手动请求几次接口,重新查看指标页面
1 | # HELP foo_errors_total Number of errors |
可以看到我们新增的指标已经添加上来了
指标名和代码中的不同?
这里按照micrometer约定,因为micrometer是一个厂商中立的api,所以为了保持统一性,代码中所有指标统一用点分隔,在具体的provider中会自动转换成目标推荐的格式,这里就按照Prometheus的格式转换成了下划线,又因为是counter类型,所以最后添加了_total后缀。
总之,在代码中统一使用点分隔即可
到这里,演示了如何使用micrometer在一个Java应用中暴露指标接口和自定义指标,其他类型参考Counter用法即可。
偶然发现Prometheus官方提供的clinet库从之前的simple_client换成了client_java,并且释出了1.0版本,这里也简单使用一下
详情参考 quickstart
首先引入依赖
1 | implementation 'io.prometheus:prometheus-metrics-core:1.0.0' |
prometheus-metrics-core
是指标库。prometheus-metrics-instrumentation-jvm
提供开箱即用的 JVM 指标。prometheus-metrics-exporter-httpserver
是一个用于公开 Prometheus 指标的独立 HTTP 服务器。如果项目已经使用Servlet等,可以使用
prometheus-exporter-servlet-jakarta
1 | import io.prometheus.metrics.core.metrics.Counter; |
这是首先初始化了prometheus-metrics-instrumentation-jvm
提供的自带的JVM指标,可以看到这里并没有Registry声明,因为通过这里可以看到client_java
默认隐式使用了一个全局的Registry,并且也推荐只使用默认实现。
然后通过CounterBuilder创建了一个Counter指标,并且设置了几个样本值,最后在9400端口启动了一个Prometheus指标端点。
分析源码可以发现
prometheus-metrics-exporter-httpserver
默认使用了jdk HttpServer实现
然后需要解释一下这里的labelNames
和labelValues
是怎么使用的,这里给出一个下面例子,相信看完你就懂了:
1 | Counter counter = Counter.builder() |
更多概念一文也无法讲清楚,若有需要推荐查看官网原文
目前client_java在2023年9月27日才发布1.0版本,所以目前官方还是推荐使用Micrometer方案。
非Spring项目中可考虑使用client_java和Micrometer皆可
Prometheus提供了多种语言的SDK用来对应用可观测性指标收集,目前,官方支持的有:
非官方支持:
]]>OpenTelemetry [təˈlemətri]指定了如何收集可观测数据并将其发送到后端平台。通过提供通用的数据格式和 API, OpenTelemetry 使组织更容易共享和重用观测数据,从而与各种可观测性工具和平台集成。
OpenTelemetry 架构促进了灵活性、互操作性和可扩展性,使开发人员能够采用满足其特定需求和环境的可观测性实践。
谷歌于2010年发表的Google Dapper,介绍了他们的分布式跟踪技术。
本文首先通过网络搜索举例。处理一个通用搜索查询可能需要数千台机器和刷多不同的服务,而用户对延迟很敏感,可能是某个子系统出现异常。但是工程师查看整体延迟只能知道可能存在问题,但是无法猜测那个服务有问题,也无法猜测为什么有问题。这里主要有下面几个问题:
上述场景对Dapper提出了两个基本要求:全面部署和持续监控。即使系统的一小部分没有受到监控,追踪的基础设施可用性也会受到影响。而且通常情况下,异常或其他值得注意的系统行为很难或不可能重现。这两个要求产生了下面三个具体的设计目标:
另一个设计目标是追踪数据在生成后可快速用于分析:最好在一分钟内。
基本概念:
其中,每棵树都有一个唯一的TraceId,用于区分不同的Trace。边表示span之间的因果关系。
span拥有下面的属性:
没有父节点的Span是根Span (root span)。
OpenTelemetry 支持不同的遥测数据类型:
OpenTelemetry API是OpenTelemetry项目的核心,它定义了一组抽象的接口和规范,用于记录应用程序性能数据、创建度量指标和传递上下文信息。这些接口和规范允许开发者编写自己的代码以收集、导出和处理性能数据。
一个库和工具的集合,使开发人员能够对其应用程序进行检测并收集遥测数据以进行监控。
Otel SDK 提供了一个标准化和可扩展的框架,用于将 OpenTelemetry 集成到各种编程语言和环境中。
OpenTelemetry为常见的编程语言提供了监控和收集可遥测数据的库,支持的语言详见https://opentelemetry.io/docs/instrumentation/
三者的关系
OpenTelemetry API提供了灵活性和可扩展性,允许开发者自定义性能数据的收集和导出方式,OpenTelemetry SDK是OpenTelemetry项目的核心实现,提供了OpenTelemetry API的具体实现细节,而OpenTelemetry Instrumentation是一组用于自动化性能监控集成的库和插件
OpenTelemetry Collector 是应用程序和后端之间的代理。它接收遥测数据,对其进行转换,然后将数据导出到可以永久存储数据的后端。Collector 还可以作为一个代理,从被监视的系统中提取遥测数据,例如,OpenTelemetry Redis 或文件系统指标。
OTLP 是 SDK 和 Collector 使用的 OpenTelemetry 协议,用于将数据导出到后端或其他收集器。作为传输协议,OTLP 可以使用 gRPC (OTLP/gRPC) 或 HTTP (OTLP/HTTP)。
OpenTelemetry Backend 负责接收、存储和分析 OpenTelemetry 收集的遥测数据。它充当数据的中央存储库或处理管道,允许您聚合、查询、可视化并从应用程序生成的遥测数据中获得见解。
OTLP。OpenTelemetry Protocol(OTLP)是一种开源的、与供应商无关的协议,用于从软件系统和应用程序收集、传输和导出遥测数据。
OTLP 定义了在检测应用程序和后端系统之间交换的连接格式和数据结构。它指定了遥测数据的编码格式,包括指标、跟踪和日志的模式,以及在网络中传输这些数据的规则。
OTLP 导出器允许将收集的遥测数据传输到后端进行处理和分析。
OpenTelemetry 架构旨在提供一种标准化的方法来收集、传输和处理来自应用程序和服务的遥测数据。它由几个关键组件组成,这些组件协同工作以实现分布式系统中的可观测性。
OpenTelemetry 提供了一种捕获可观测性信号的标准化方法:
OpenTelemetry Metrics 是帮助量化系统行为的定量指标。它们提供有关应用程序某些方面的当前状态或速率的信息,例如 CPU 使用情况、内存消耗或请求延迟。OpenTelemetry 允许您定义和记录自定义指标,以监视应用程序的性能和运行状况。
OpenTelemetry Traces 提供了请求在分布式系统中执行路径的详细记录。它们捕获单个操作及其关系的时间信息,使您能够了解请求流、确定瓶颈并解决性能问题。使用 OpenTelemetry,您可以对代码进行检测,以生成分布式跟踪并在服务之间进行关联。
OpenTelemetry Logs 是在应用程序执行期间发生的事件或消息的文本记录。它们帮助您理解应用程序行为、诊断问题和审计活动。OpenTelemetry 提供了一种机制,可以从应用程序中捕获结构化日志,并用上下文信息丰富它们。
OpenTelemetry Metrics 是关于如何收集、聚合指标并将指标发送到OpenTelemetry APM 的标准,如Prometheus 等工具。
指标是代表系统运行状况和性能的数字数据点,例如 CPU 利用率、网络流量和数据库连接。
您可以使用指标来测量、监控和比较性能,例如,您可以测量服务器响应时间、内存利用率、错误率等。
prometheus指标可以用OpenTelemetry Collector收集和暴露prometheus指标。可以配置 OpenTelemetry Collector 接受 OpenTelemetry 数据,然后使用Prometheus 远程写入协议保存到到 Prometheus。
或者执行相反的操作,使用 OpenTelemetry Collector 提取 Prometheus 指标并将其使用OTLP导出到OpenTelemetry 后端
OpenTelemetry Collector本身可以充当Prometheus Server来抓取数据,收集并处理,然后重新暴露出来(exporter)。
OpenTelemetry Collector Prometheus Exporter 是一个组件,允许将指标从OpenTelemetry Collector 暴露到Prometheus监控系统,也可以添加额外的标签和元数据。
通过使用OpenTelemetry Instruments,可以自动或者手动的将应用程序指标暴露出来。支持的类型有:
类型 | 属性 | 描述 |
---|---|---|
Counter | 同步 单调 | 用于描述加法非递减值 |
CounterObserver | 异步 单调 | Counter的异步版本 |
UpDownCounter | 同步 | 可测量随时间增加或减少的附加值 |
UpDownCounterObserver | 异步 | UpDownCounter的异步版本 |
Histogram | 同步 | 可根据记录的值生成直方图 |
GaugeObserver | 异步 | 用于测量非相加值 |
具体用法参考各语言SDK实现,暴露出一个Prometheus指标端点即可。
跟踪为我们提供了向应用程序发出请求时发生的情况的总体情况。无论您的应用程序是具有单个数据库的整体应用程序还是复杂的服务网格,跟踪对于了解请求在应用程序中采用的完整“路径”都至关重要。
分布式追踪允许您查看请求如何通过不同的服务和系统、每个操作的时间、发生时的任何日志和错误。
分布式追踪工具提供对系统行为的可见性,帮助识别性能问题,协助调试,并帮助确保分布式应用程序的可靠性和可伸缩性。
OpenTelemetry 跟踪在微服务架构的上下文中特别有价值,在微服务架构中,应用程序由多个独立的服务组成,共同工作以满足用户请求。
span表示一个工作/操作基本单元。一个Trace Tree由多个span组成。在OpenTelemetry中,span包含下面的信息:
比如:
1 | { |
span允许嵌套,表示父子操作。
上下文传播可确保相关上下文数据(例如跟踪 ID、跨度 ID 和其他元数据)在应用程序的不同服务和组件之间一致地传播。
OpenTemetry 在进程内的函数之间传播上下文(进程内传播),甚至从一个服务传播到另一个服务(分布式传播)。
进程内传播可以是隐式的或显式的,具体取决于您使用的编程语言。隐式传播是通过将活动上下文存储在线程局部变量(Java、Python、Ruby、NodeJS)中自动完成的。显式传播需要将活动上下文作为参数从一个函数显式传递到另一个函数 (Go)。
对于分布式上下文传播,OpenTelemetry 支持多种定义如何序列化和传递上下文数据的协议:
traceparent
头中,例如traceparent=00-84b54e9330faae5350f0dd8673c98146-279fa73bc935cc05-01
.x-b3-
,例如X-B3-TraceId
.W3C 跟踪上下文是默认启用的推荐传播器。
OpenTelemetry Logs 允许以能够与其他可观测信号进行关联和更好集成的方式记录和收集日志。
日志对于了解应用程序行为、诊断问题和监控系统运行状况至关重要。虽然分布式跟踪和指标可以提供有关系统性能的相关信息,但日志可以提供有关特定事件、错误和应用程序行为的详细上下文和信息。
OpenTelemetry 提供了一个日志记录 API,允许您检测应用程序并生成结构化日志。OpenTelemetry Logging API 旨在与其他遥测数据(例如指标和跟踪)配合使用,以提供统一的可观测性解决方案。
OpenTelemetry 支持从应用程序或系统内的各种来源捕获日志。根据日志的生成和收集方式,日志可以分为 3 类。
系统日志提供有关系统操作、性能和安全性的宝贵信息。系统日志通常由系统内的各个组件生成,包括操作系统、应用程序、网络设备和服务器。
系统日志是在主机级别写入的,具有预定义的格式和内容,无法轻易更改。系统日志不包含有关跟踪上下文的信息。
比如内核日志,系统服务日志。
第一方日志由内部应用程序生成,记录特定的应用程序事件、错误和用户活动。这些日志对于应用程序调试和故障排除很有用。
通常,开发人员可以修改这些应用程序以更改日志的写入方式以及包含的信息。例如,要将日志与跟踪关联起来,开发人员可以手动将跟踪上下文添加到每个日志语句中,或者使用日志库的插件自动执行此操作。
例如,要传播上下文并将日志记录与跨度关联,您可以在日志消息中使用以下属性:
trace_id
对于 TraceId,十六进制编码。span_id
对于 SpanId,十六进制编码。trace_flags
对于跟踪标志,根据 W3C 跟踪标志格式进行格式化。例如:
1 | request failed trace_id=958180131ddde684c1dbda1aeacf51d3 span_id=0cf859e4f7510204 |
开始新项目时,您可以遵循 OpenTelemetry 的建议和最佳实践,了解如何使用自动检测或配置日志记录库以使用 OpenTelemetry 日志附加程序来发出日志。
OpenTelemetry 的日志 API 允许开发人员对其应用程序进行检测,以生成可以由日志记录后端或日志管理系统收集和处理的结构化日志。日志 API 提供了一种将附加上下文信息附加到日志的方法,例如标签、属性或元数据。
使用 Logger API 记录不同严重级别的事件或消息,例如debug
、info
、warning
、error
等。您还可以将其他属性或上下文附加到日志以提供更多信息。
OpenTelemetry 还提供了一种标准化方法来跨分布式系统传播日志中的上下文。这确保了一致地捕获和保留相关的执行上下文,即使日志是由系统的不同组件生成的。
OpenTelemetry 日志数据模型允许将TraceId
和SpanId
直接包含在LogRecords
.
通过OpenTelemetry,我们可以在分布式环境中,跨语言,对服务的可遥测数据(Traces, Metrics, Logs)进行标准化的,统一的定义,埋点,收集,处理和存储,降低实施可观测性的成本。
]]>历史上,Linux 的启动一直采用 init 进程。
下面的命令用来启动服务。
1 | sudo /etc/init.d/apache2 start |
这种方法有两个缺点。
一是启动时间长。init 进程是串行启动,只有前一个进程启动完,才会启动下一个进程。
二是启动脚本复杂。init 进程只是执行启动脚本,不管其他事情。脚本需要自己处理各种情况,这往往使得脚本变得很长。
Systemd 就是为了解决这些问题而诞生的。
它的设计目标是
为系统的启动和管理提供一套完整的解决方案
Systemd 是一系列工具的集合,其作用也远远不仅是启动操作系统,它还接管了后台服务、结束、状态查询,以及日志归档、设备管理、电源管理、定时任务等许多职责,并支持通过特定事件(如插入特定 USB 设备)和特定端口数据触发的 On-demand(按需)任务。
Systemd 的后台服务还有一个特殊的身份——它是系统中 PID 值为 1 的进程。
更少的进程
Systemd 提供了 服务按需启动 的能力,使得特定的服务只有在真定被请求时才启动。
允许更多的进程并行启动
在 SysV-init 时代,将每个服务项目编号依次执行启动脚本。Ubuntu 的 Upstart 解决了没有直接依赖的启动之间的并行启动。而 Systemd 通过 Socket 缓存、DBus 缓存和建立临时挂载点等方法进一步解决了启动进程之间的依赖,做到了所有系统服务并发启动。对于用户自定义的服务,Systemd 允许配置其启动依赖项目,从而确保服务按必要的顺序运行。
使用 CGroup 跟踪和管理进程的生命周期
在 Systemd 之间的主流应用管理服务都是使用 进程树 来跟踪应用的继承关系的,而进程的父子关系很容易通过 两次 fork 的方法脱离。
而 Systemd 则提供通过 CGroup 跟踪进程关系,引补了这个缺漏。通过 CGroup 不仅能够实现服务之间访问隔离,限制特定应用程序对系统资源的访问配额,还能更精确地管理服务的生命周期。
统一管理服务日志
Systemd 是一系列工具的集合, 包括了一个专用的系统日志管理服务:Journald。这个服务的设计初衷是克服现有 Syslog 服务的日志内容易伪造和日志格式不统一等缺点,Journald 用 二进制格式 保存所有的日志信息,因而日志内容很难被手工伪造。Journald 还提供了一个 journalctl 命令来查看日志信息,这样就使得不同服务输出的日志具有相同的排版格式, 便于数据的二次处理。
Systemd 可以管理所有系统资源,不同的资源统称为 Unit(单位)。
在 Systemd 的生态圈中,Unit 文件统一了过去各种不同系统资源配置格式,例如服务的启/停、定时任务、设备自动挂载、网络配置、虚拟内存配置等。而 Systemd 通过不同的文件后缀来区分这些配置文件。
Systemd 支持的 12 种 Unit 文件类型
按照 systemd 的约定,unit 文件应该方案指定的三个位置之一。
这三个目录具有优先级,越靠上的优先级越高
文件结构
1 | [Unit] |
如下所示,Systemd 服务的 Unit 文件可以分为三个配置区段:
这部分配置的目标模块通常是特定运行目标的 .target 文件,用来使得服务在系统启动时自动运行。这个区段可以包含三种启动约束:
1 | # find /etc/systemd/system/* -type d |
用来 Service 的配置,只有 Service 类型的 Unit 才有这个区块。它的主要字段分为服务生命周期和服务上下文配置两个方面。
服务生命周期控制相关
Type:定义启动时的进程行为,它有以下几种值:
Type=simple:默认值,执行 ExecStart 指定的命令,启动主进程
Type=forking:以 fork 方式从父进程创建子进程,创建后父进程会立即退出
Type=oneshot:一次性进程,Systemd 会等当前服务退出,再继续往下执行
Type=dbus:当前服务通过 D-Bus 启动
Type=notify:当前服务启动完毕,会通知 Systemd,再继续往下执行
Type=idle:若有其他任务执行完毕,当前服务才会运行
RemainAfterExit:值为 true 或 false(默认)。当配置为 true 时,Systemd 只会负责启动服务进程,之后即便服务进程退出了,Systemd 也仍然会认为这个服务还在运行中。这个配置主要是提供给一些并非常驻内存,而是启动注册后立即退出,然后等待消息按需启动的特殊类型服务使用的。
ExecStart:启动当前服务的命令
ExecStartPre:启动当前服务之前执行的命令
ExecStartPos:启动当前服务之后执行的命令
ExecReload:重启当前服务时执行的命令
ExecStop:停止当前服务时执行的命令
ExecStopPost:停止当其服务之后执行的命令
RestartSec:自动重启当前服务间隔的秒数
Restart:定义何种情况 Systemd 会自动重启当前服务,可能的值包括 always(总是重启)、on-success、on-failure、on-abnormal、on-abort、on-watchdog
TimeoutStartSec:启动服务时等待的秒数,这一配置对于使用 Docker 容器而言显得尤为重要,因其第一次运行时可能需要下载镜像,严重延时会容易被 Systemd 误判为启动失败杀死。通常,对于这种服务,将此值指定为 0,从而关闭超时检测
TimeoutStopSec:停止服务时的等待秒数,如果超过这个时间仍然没有停止,Systemd 会使用 SIGKILL 信号强行杀死服务的进程
服务上下文配置相关
Environment:为服务指定环境变量
EnvironmentFile:指定加载一个包含服务所需的环境变量的列表的文件,文件中的每一行都是一个环境变量的定义
Nice:服务的进程优先级,值越小优先级越高,默认为 0。其中 -20 为最高优先级,19 为最低优先级
WorkingDirectory:指定服务的工作目录
RootDirectory:指定服务进程的根目录(/ 目录)。如果配置了这个参数,服务将无法访问指定目录以外的任何文件
User:指定运行服务的用户
Group:指定运行服务的用户组
MountFlags:服务的 Mount Namespace 配置,会影响进程上下文中挂载点的信息,即服务是否会继承主机上已有挂载点,以及如果服务运行执行了挂载或卸载设备的操作,是否会真实地在主机上产生效果。可选值为 shared、slaved 或 private
shared:服务与主机共用一个 Mount Namespace,继承主机挂载点,且服务挂载或卸载设备会真实地反映到主机上
slave:服务使用独立的 Mount Namespace,它会继承主机挂载点,但服务对挂载点的操作只有在自己的 Namespace 内生效,不会反映到主机上
private:服务使用独立的 Mount Namespace,它在启动时没有任何任何挂载点,服务对挂载点的操作也不会反映到主机上
LimitCPU / LimitSTACK / LimitNOFILE / LimitNPROC 等:限制特定服务的系统资源量,例如 CPU、程序堆栈、文件句柄数量、子进程数量等
在 Unit 文件中,有时会需要使用到一些与运行环境有关的信息,例如节点 ID、运行服务的用户等。这些信息可以使用占位符来表示,然后在实际运行被动态地替换实际的值。
Shell是Linux/Unix的一个外壳,你理解成衣服也行。它负责外界与Linux内核的交互,接收用户或其他应用程序的命令,然后把这些命令转化成内核能理解的语言,传给内核,内核是真正干活的,干完之后再把结果返回用户或应用程序。
Linux/Unix提供了很多种Shell,为毛要这么多Shell?难道用来炒着吃么?那我问你,你同类型的衣服怎么有那么多件?花色,质地还不一样。写程序比买衣服复杂多了,而且程序员往往负责把复杂的事情搞简单,简单的事情搞复杂。牛程序员看到不爽的Shell,就会自己重新写一套,慢慢形成了一些标准,常用的Shell有这么几种,sh、bash、csh等,想知道你的系统有几种shell,可以通过以下命令查看:
1 | cat /etc/shells |
目前常用的 Linux 系统的默认 Shell 都是 bash,但是真正强大的 Shell 是深藏不露的 zsh, 绝对是马车中的跑车,跑车中的战斗机,史称『终极 Shell』,但是由于配置过于复杂,所以初期无人问津,很多人跑过来看看 zsh 的配置指南,什么都不说转身就走了。直到有一天,国外有个穷极无聊的程序员开发出了一个能够让你快速上手的zsh项目,叫做「oh my zsh」。这玩意就像「X天叫你学会 C++」系列,可以让你神功速成。
好,下面我们看看如何安装、配置和使用 zsh。
如果你用 Mac,就可以直接看下一节,默认就是zsh
如果你用 Ubuntu Linux,执行:
1 | sudo apt install zsh |
如果你用 Windows……去洗洗睡吧。
安装完成后设置当前用户使用 zsh:
1 | chsh -s /bin/zsh |
根据提示输入当前用户的密码就可以了。
首先安装 git,安装方式同上。
1 | sudo apt install git |
安装「oh my zsh」可以自动安装也可以手动安装。
自动安装:
1 | wget https://github.com/robbyrussell/oh-my-zsh/raw/master/tools/install.sh -O - | sh |
手动安装:
1 | git clone git://github.com/robbyrussell/oh-my-zsh.git ~/.oh-my-zsh |
都不复杂,安装完成之后退出当前会话重新打开一个终端窗口,看到终端提示符变化了,就代表安装完成了:
zsh 的配置主要集中在用户当前目录的.zshrc里,用 vim 或你喜欢的其他编辑器打开.zshrc,在最下面会发现这么一行字:
# Customize to your needs…
可以在此处定义自己的环境变量和别名,当然,oh my zsh 在安装时已经自动读取当前的环境变量并进行了设置
zsh 的厉害之处在于不仅可以设置通用别名,还能针对文件类型设置对应的打开程序,比如:
1 | alias -s html=gedit |
意思就是你在命令行输入 hello.html,zsh会为你自动打开 gedit 并读取 hello.html
1 | alias -s gz='tar -xzvf |
表示自动解压后缀为 gz 的压缩包。
总之,只有想不到,没有做不到。
设置完环境变量和别名之后,基本上就可以用了,如果你是个主题控,还可以玩玩 zsh 的主题。在 .zshrc 里找到ZSH_THEME,就可以设置主题了,默认主题是:
ZSH_THEME=”robbyrussell”
oh my zsh 提供了数十种主题,相关文件在~/.oh-my-zsh/themes目录下,你可以随意选择,也可以编辑主题满足自己的变态需求
oh my zsh 项目提供了完善的插件体系,相关的文件在~/.oh-my-zsh/plugins目录下,默认提供了100多种,可以根据自己的实际工作环境采用,想了解每个插件的功能,只要打开相关目录下的 zsh 文件看一下就知道了。插件也是在.zshrc里配置,找到plugins关键字,你就可以加载自己的插件了,系统默认加载 git ,你可以在后面追加内容,如下:
1 | plugins=(git autojump mvn gradle) |
1、兼容 bash,原来使用 bash 的兄弟切换过来毫无压力,该咋用咋用。
2、强大的历史纪录功能,输入 grep 然后用上下箭头可以翻阅你执行的所有 grep 命令。
3、智能拼写纠正,输入gtep mactalk * -R,系统会提示:zsh: correct ‘gtep’ to ‘grep’ [nyae]? 比妹纸贴心吧,她们向来都是让你猜的……
4、各种补全:路径补全、命令补全,命令参数补全,插件内容补全等等。触发补全只需要按一下或两下 tab 键,补全项可以使用 ctrl+n/p/f/b上下左右切换。比如你想杀掉 java 的进程,只需要输入 kill java + tab键,如果只有一个 java 进程,zsh 会自动替换为进程的 pid,如果有多个则会出现选择项供你选择。ssh + 空格 + 两个tab键,zsh会列出所有访问过的主机和用户名进行补全
5、目录浏览和跳转:输入 d,即可列出你在这个会话里访问的目录列表,输入列表前的序号,即可直接跳转。
6、在当前目录下输入 .. 或 … ,或直接输入当前目录名都可以跳转,你甚至不再需要输入 cd 命令了。
7、通配符搜索:ls -l **/*.sh,可以递归显示当前目录下的 shell 文件,文件少时可以代替 find,文件太多就歇菜了。
8、更强的别名
9、插件支持
……
看完这篇文章,你就知道,zsh一出,无人再与争锋!终极二字不是盖的。
如果你是个正在使用 shell程序员,如果你依然准备使用 bash,那就去面壁和忏悔吧,别说你是程序员…
感谢那位开发了 oh my zsh 的无聊程序员,他可能没有因此收获物质上的利益,但是他的代码提升了无数程序员的效率,节省了大量的时间,我们说,程序员改变世界!
在这之前,希望你至少按照ArchWiki中的安装向导,在安装过程中遇到问题之后再来参考本文。
最推荐的方式还是去官网下载,网站下方除了提供种子外还有各个国家的镜像站,选择一个离自己物理位置近的下载即可。
下载完成就是一个大约 800M 大小的 iso 镜像,可以看到,相对于动辄好几G的其他常见的发行版,archlinux 可以说是非常的轻量,基本上允许你从头一步一步安装一个属于你自己的 archlinux !
我一般会选择图形化页面的软件方便烧录,这里推荐 Etcher , 提供了Windows, MacOS, Linux所有平台的安装包。
按照如图所示的顺序很方便的就能烧录好U盘,最后重启电脑进入U盘启动,就能愉快的开始安装啦😃
安装过程中会输入大量的命令,如果害怕敲错,可以用另一台设备 ssh 连接过来远程操作安装,方便复制命令,操作步骤如下:
1 | passwd |
1 | ip addr |
找到当前网卡的ip地址即可,一般可能是 172.xxx.xxx.xxx
或者 192.168.xxx.xxx
,并记下来。
1 | ssh root@172.xxx.xxx.xxx |
输入密码后就能连接上了。之后安装本地安装的顺序继续走就可以。
如果是第一次安装或者抱着学习的态度,还是建议本地安装,亲自敲一遍命令,你可以清楚的知道一个 linux 设备从零到能用中间基本经历了什么操作。
这大概也是 Archlinux 的魅力之一吧,常见的 Ubuntu 等安装时都提供了图形界面安装,只需要一直点击下一步就能成功安装,小白可能安装会快点,但是作为计算机从业人员,如果如果连 Linux 基本的系统结构都不清楚的话,还是不太合适的。
如果是网线连接,一般会自动连接上,可以跳过这部分。
1 | nmcli dev wifi connect ChinaNet-sj8h password 12345678 |
其中,ChinaNet-sj8h
部分是 WiFi 的名字,后面的 12345678
是你的 WiFi 密码。
有时候会遇到连接 WiFi 的时候提示网卡被禁用,可以通过下面的操作恢复:
1 | rfkill # 查看被禁用的设备 |
连接完成后,执行 ping baidu.com
可以检查网络是否正常。baidu 每天承受大量ddos攻击
1 | timedatectl set-ntp true |
如果没有任何输出,就没任何问题。
设置仓库地址为国内镜像源,下载更快。通过下面的命令自动选择当前延迟最低的站点:
1 | reflector --country China --age 72 --sort rate --protocol https --save /etc/pacman.d/mirrorlist |
同步之后刷新本地的仓库索引
1 | pacman -Sy |
查看所有的硬盘信息,确定要安装到哪里
1 | fdisk -l |
此命令会列出当前所有的硬盘。找到需要安装的位置。
1 | # fdisk -l |
因为我的设备有三块硬盘,所以会显示三个,然后我想安装到其中的 /dev/nvme1n1
,然后执行下面的命令
1 | cfdisk /dev/nvme1n1 |
cfdisk
命令会进入一个带简单的 GUI 的页面分区,首先最好删除所有的分区,然后划分一个 500M 空间的分区, Type
设置为 EFI System
,剩下的空间按照需要再分一个较大的空间,Type
设置为 Linux Filesystem
即可。然后选择 Write
写入后退出即可。
可能也会包括移动设备,注意观察每个盘的空间大小
1 | mkfs.fat -F32 /dev/nvme1n1p1 |
1 | mkfs.f2fs -f -l Archlinux /dev/nvme1n1p2 |
-f是强制执行,-l后面的是这个分区的Label也就是名字自己想写什么都可以, 类似windows中c、d盘前面的名字
分区之后,将硬盘挂载到当前的 Live 系统。
1 | mount /dev/nvme1n1p2 /mnt |
/mnt
就是你将要安装的系统的根目录,然后将 EFI 目录挂载到 boot 目录。
现在开始,就是真正安装你的系统了,通过 Live 系统中的 pacstrap 脚本将内核等基础软件安装的目标硬盘。
1 | pacstrap /mnt linux linux-firmware linux-headers base base-devel bash-completion vim git intel-ucode f2fs-tools networkmanager wqy-zenhei openssh |
如果你的跟分区是f2fs,f2fs-tools
是必须的
intel-ucode
是根据你的cpu来的如果你的是amd的那就是amd-ucode
networkmanager
也是必须的,因为你要联网,而且plasma和gnome都通过是networkmanager
来管理网络的
wqy-zenhei
是中文字体,避免安装界面后乱码
1 | genfstab -U /mnt >> /mnt/etc/fstab |
第一个命令是生成开机自动挂载配置,生成完之后一定要查看一下内容,确定是你刚才挂载的硬盘,否则会无法启动。
现在开始系统运行所需要的包括内核等基础软件已经安装完成,需要通过 arch-chroot
切换到新系统来初始化。
1 | arch-chroot /mnt |
chroot
指的就是以 root 身份进入当前挂载的系统,后续如果系统出现问题,也可以通过 chroot 来进行修复。
1 | ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime |
1 | vim /etc/locale.gen |
在当前文件中取消注释en_US.UTF-8 UTF-8
和 zh_CN.UTF-8 UTF-8
这两行,然后保存退出。
然后执行
1 | locale-gen |
初始化语言。
1 | echo "en_US.UTF-8" > /etc/locale.conf |
这里设置英语为默认语言。
不推荐在这里设置系统默认语言为中文,这样会生成很多中文路径,后续安装会比较麻烦。
1 | systemctl enable sshd NetworkManager |
1 | bootctl install |
编辑引导内容
1 | vim /boot/loader/loader.conf |
文件内容参考下面
1 | default arch |
然后编辑/boot/loader/entries/arch.conf
文件
1 | title Arch Linux |
title可以随便写,意思就是当前系统的命名
linux 这里是linux的内核,通过bootctl install就已经把内核复制到了/boot下
initrd 这是要加载的模块,我这里加了intel-ucode.img和initramfs-linux.img
options 这是启动附加的参数
在 vim 中执行
:r!blkid
可以输出当前硬盘信息删除其他的,保留UUID即可
1 | mkdir /etc/pacman.d/hooks |
文件内容如下
1 | [Trigger] |
1 | passwd |
因为 root 的权限太高,日常使用中非常容易误操作产生不可逆的破坏,所以一定要使用一个低权限账号来日常使用。
1 | useradd -m chaofan |
1 | usermod -aG wheel,users,storage,power,lp,adm,optical chaofan |
1 | visudo |
操作方式和 vim 相同,取消注释 wheel ALL=(ALL:ALL) ALL
这一行即可。
1 | passwd chaofan |
配置完成后,重启即可
1 | exit |
重启后记得拔掉 U盘,进入新系统。
重启后依然是熟悉的终端
输入刚才创建的用户名和密码登陆即可,然后重新连接好网络。
首先编辑 pacman 的配置文件。
1 | sudo vim /etc/pacman.conf |
添加 archlinuxcn 源
1 | [archlinuxcn] |
然后取消 [multlib]
的注释。然后安装 yay
这是一个 AUR 管理器,可以安装来自 AUR 的包,使用方法和 pacman 相同。
1 | sudo pacman -S yay |
linux 下常见的桌面环境包括KDE和Gnome,我比较喜欢 gnome,整体风格更统一。
首先安装显卡驱动
1 | yay -S xf86-video-intel vulkan-driver mesa-vdpau libva-mesa-driver |
然后安装桌面环境
1 | yay -S gnome |
设置启动管理器开机自启
1 | sudo systemctl enable gdm |
最后重启,这样桌面环境就成功安装完毕了。
]]>Google Guava Cache是一种非常优秀本地缓存解决方案,提供了基于容量,时间和引用的缓存回收方式,他的优点是封装了get,put操作;提供线程安全的缓存操作;提供过期策略;提供回收策略;缓存监控。当缓存的数据超过最大值时,使用LRU算法替换。这一篇我们将要谈到一个新的本地缓存框架:Caffeine Cache。它也是站在巨人的肩膀上-Guava Cache,借着他的思想优化了算法发展而来。
为什么需要本地缓存
相对于IO操作,速度快,效率高
相对于Redis,Redis是一种优秀的分布式缓存实现,但受限于网卡等原因,远水救不了近火
什么时候用
怎么用
常见的缓存淘汰算法还有LRU, FIFO, LFU:
LRU:最近最少使用算法,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。仍然有个问题,如果有个数据在 1 分钟访问了 1000次,再后 1 分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
LRU的优点和局限性:LRU可以很好的应对突发流量的情况,因为他不需要累计数据频率。但LRU通过历史数据来预测未来是局限的,它会认为最后到来的数据是最可能被再次访问的,从而给与它最高的优先级。
FIFO:先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰,会导致命中率很低。
LFU:最近最少频率使用,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。
上面三种策略各有利弊,实现的成本也是一个比一个高,同时命中率也是一个比一个好。Guava Cache虽然有这么多的功能,但是本质上还是对LRU的封装,如果有更优良的算法,并且也能提供这么多功能,相比之下就相形见绌了。
LFU的局限性:在 LFU 中只要数据访问模式的概率分布随时间保持不变时,其命中率就能变得非常高。比如有部新剧出来了,我们使用 LFU 给他缓存下来,这部新剧在这几天大概访问了几亿次,这个访问频率也在我们的 LFU 中记录了几亿次。但是新剧总会过气的,比如一个月之后这个新剧的前几集其实已经过气了,但是他的访问量的确是太高了,其他的电视剧根本无法淘汰这个新剧,所以在这种模式下是有局限性。
在现有算法的局限性下,会导致缓存数据的命中率或多或少的受损,而命中略又是缓存的重要指标。HighScalability网站刊登了一篇文章,由前Google工程师发明的W-TinyLFU: 一种现代的缓存 。Caffine Cache就是基于此算法而研发。Caffeine 因使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率。
当数据的访问模式不随时间变化的时候,LFU的策略能够带来最佳的缓存命中率。然而LFU有两个缺点:
它需要给每个记录项维护频率信息,每次访问都需要更新,这是个巨大的开销;
如果数据访问模式随时间有变,LFU的频率信息无法随之变化,因此早先频繁访问的记录可能会占据缓存,而后期访问较多的记录则无法被命中。
因此,大多数的缓存设计都是基于LRU或者其变种来进行的。相比之下,LRU并不需要维护昂贵的缓存记录元信息,同时也能够反应随时间变化的数据访问模式。然而,在许多负载之下,LRU依然需要更多的空间才能做到跟LFU一致的缓存命中率。因此,一个“现代”的缓存,应当能够综合两者的长处。
Maven
1 | <dependency> |
Gradle (Kotlin DSL)
1 | implementation("com.github.ben-manes.caffeine:caffeine:3.1.2") |
1 | public Object manulOperator(String key) { |
可配置项
1 | initialCapacity=[integer]: 初始的缓存空间大小 |
构造Cache时候,build方法传入一个CacheLoader实现类。实现load方法,通过key加载value。
1 | public Object syncOperator(String key){ |
AsyncLoadingCache是继承自LoadingCache类的,异步加载使用Executor去调用方法并返回一个CompletableFuture。异步加载缓存使用了响应式编程模型。
如果要以同步方式调用时,应提供CacheLoader。要以异步表示时,应该提供一个AsyncCacheLoader,并返回一个CompletableFuture。
1 | public Object asyncOperator(String key){ |
Caffeine提供了3种回收策略:基于大小回收,基于时间回收,基于引用回收。
基于大小的回收策略有两种方式:一种是基于缓存大小,一种是基于权重。
1 | // 根据缓存的计数进行驱逐 |
maximumWeight与maximumSize不可以同时使用。
1 | // 基于固定的到期策略进行退出 |
Caffeine提供了三种定时驱逐策略:
expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。expireAfterWrite(long, TimeUnit): 在最后一次写入缓存后开始计时,在指定的时间后过期。expireAfter(Expiry): 自定义策略,过期时间由Expiry实现独自计算。缓存的删除策略使用的是惰性删除和定时删除。这两个删除策略的时间复杂度都是O(1)。
Java中四种引用类型
引用类型被垃圾回收时间用途生存时间
1 | // 当key和value都没有引用时驱逐缓存 |
注意:AsyncLoadingCache不支持弱引用和软引用。
Caffeine.weakKeys():使用弱引用存储key。如果没有其他地方对该key有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。
Caffeine.weakValues() :使用弱引用存储value。如果没有其他地方对该value有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。
Caffeine.softValues() :使用软引用存储value。当内存满了过后,软引用的对象以将使用最近最少使用(least-recently-used ) 的方式进行垃圾回收。由于使用软引用是需要等到内存满了才进行回收,所以我们通常建议给缓存配置一个使用内存的最大值。softValues() 将使用身份相等(identity) (==) 而不是equals() 来比较值。
Caffeine.weakValues()和Caffeine.softValues()不可以一起使用。
1 | Cache<String, Object> cache = Caffeine.newBuilder() |
CacheWriter 方法可以将缓存中所有的数据写入到第三方。
1 | LoadingCache<String, Object> cache2 = Caffeine.newBuilder() |
如果你有多级缓存的情况下,这个方法还是很实用。
注意:CacheWriter不能与弱键或AsyncLoadingCache一起使用。
与Guava Cache的统计一样。
1 | Cache<String, Object> cache = Caffeine.newBuilder() |
通过使用Caffeine.recordStats(), 可以转化成一个统计的集合. 通过 Cache.stats() 返回一个CacheStats。CacheStats提供以下统计方法:
1 | hitRate(): 返回缓存命中率 |
一种常见的状态破坏被称为缓冲区溢出。一般在栈中分配一个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。就像下面这样:
1 | // Implementation of library function gets() |
这是库函数gets
的一个实现。它从标准输入读入一行,在遇到一个回车换行字符或者某个错误情况时停止。然后把这个字符串复制到参数s指定的位置,并在结尾加上null字符。echo
调用了这个函数然后送回到标准输出。
这个函数的问题是它没有办法确定字符串是否有足够的空间,我们将buf
设置的比较小,这样任何长度超过7的字符串都会导致越界。
检查gcc为echo产生的汇编代码:
1 | echo: |
该程序在栈上分配了24个字节。buf位于栈顶,然后把%rsp复制到%rdi作为调用gets和puts的参数。其中有16个字节未被使用。只要输入的字符串超过7个字节,过长的字符串就会覆盖栈上存储的某些信息。
输入的字符串数量 | 附加的被破坏的状态 |
---|---|
0-7 | 无 |
9-23 | 未被使用的栈空间 |
24-31 | 返回地址 |
32+ | caller中保存的状态 |
在23个字符之前都没有严重的后果,但是超过以后就会破坏存储的返回位置,ret指令会导致程序跳转到一个完全意想不到的位置。
缓冲区溢出的更加致命的问题就是会让程序执行它本来不会执行的代码。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入一个字符串,这个字符串包含一些可执行的字节编码,称为攻击代码,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret的效果就是跳转到攻击代码。
常见的攻击形式有两种:
现代编译器和操作系统实现了很多机制,以避免遭受这样的攻击。
为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在以前,程序的栈地址非常容易预测。对于所有运行同样程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此攻击者可以确定一个常见的Web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击。这种现象被称作安全单一化。
栈随机化的思想使得栈的位置在程序每次运行时都会有变化,这样在不同机器上执行同样的代码,他们的栈地址都是不同的。实现的方式是:程序开始时,在栈上分配一个0-n字节之间的随机大小的空间,程序不使用这段空间,但是它会导致每次执行后续的栈位置发生了变化。可以通过下面的代码确定栈的地址。
1 | int main() { |
在64位的操作系统上的范围大约是$2^{32}$。
在linux系统中,这类技术乘坐**地址空间布局随机化(ASLR)**,采用ASLR,每次运行时程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。
但是这种技术也有漏洞,攻击者可以在攻击代码前插入很长的一段
nop
。这个指令唯一作用就是让PC加一,指向下一条指令。只要攻击者能够猜中这段序列的某个地址,程序就会“滑过”这个序列。
在最新的GCC中加入了一种**栈保护者(stack protector)机制来检测缓冲区越界。其思想时在栈帧中任何布局缓冲区和栈状态之家存储一个特殊的金丝雀(canary)值,也称作哨兵值(guard value)**,在程序每次运行时随机产生,因此攻击者没有简单的办法能知道它时什么。在恢复寄存器状态和从函数返回之前,会检查这个值是否被修改。如果是,那么程序异常中止。
可以通过
-fno-stack-protector
选项来阻止GCC产生这种代码。
栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是GCC只在函数中有局部char类型缓冲区的时候才插入这样的代码。
典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分被限制为只允许读和写。
有些类型的程序要求动态产生和执行代码的能力,比如使用JIT的Java动态的产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。
]]>元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。从结构体存储的首地址开始,每个元素放置到内存中时,它都会认为内存是按照自己的大小(通常它为4或8)来划分的,因此元素放置的位置一定会在自己宽度的整数倍上开始,这就是所谓的内存对齐。
编译器为程序中的每个“数据单元”安排在适当的位置上。C语言允许你干预“内存对齐”。如果你想了解更加底层的秘密,“内存对齐”对你就不应该再模糊了。
理论上,int占4byte,char占一个byte,将他们放在一个结构体里面,这个结构体应该占5byte,但是实际上却不是这样,这就是内存对齐导致的。
1 |
|
1 | struct { |
默认#pragma pack(4),且结构体中最长的数据类型为4个字节,所以有效对齐单位为4字节,下面根据上面所说的规则以第二个结构体来分析其内存布局: 首先使用规则1,对成员变量进行对齐:
然后使用规则2,对结构体整体进行对齐:
第二个结构体中变量i占用内存最大占4字节,而有效对齐单位也为4字节,两者较小值就是4字节。因此整体也是按照4字节对齐。由规则1得到s2占9个字节,此处再按照规则2进行整体的4字节对齐,所以整个结构体占用12个字节。
更改C编译器的缺省字节对齐方式:
在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件:
另外,还有如下的一种方式:
不同平台上编译器的 pragma pack 默认值不同。而我们可以通过预编译命令#pragma pack(n), n= 1,2,4,8,16来改变对齐系数。
例如,对于上个例子的三个结构体,如果前面加上#pragma pack(1),那么此时有效对齐值为1字节,此时根据对齐规则,不难看出成员是连续存放的,三个结构体的大小都是6字节。
c++11以后引入两个关键字 alignas
与 alignof
。其中alignof
可以计算出类型的对齐方式,alignas
可以指定结构体的对齐方式。
但是alignas
在某些情况下是不能使用的,具体见下面的例子:
1 | struct Info { |
原子(atomic)指的是“不能被进一步分割的最小粒子”,原子操作指的是“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就会变得很复杂。
32位IA32处理器使用的是基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个原子时,其他处理器不能访问这个字节的内存地址。现代的处理器能自动保证其原子性,比如跨总线宽度,跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证内存操作的原子性。
如果多个处理器同时对共享变量进行读改写操作(比如i++),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。
比如i=1,进行两次i++操作,期望的结果是3,但是有可能结果是2
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
所以处理器使用总线锁来解决这个问题。
总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器就可以独享共享内存。
使用总线锁的时候把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L1,L2,L3告诉缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,现代处理器中就可以使用“缓存锁定”方式来实现复杂的原子性,那么当他执行锁操作回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
但是两种情况下处理器不会使用缓存锁定
java中可以通过锁和循环CAS的方式来实现原子操作。
JVM中的CAS操作就是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作知道成功为止。
下面是一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。
1 | public class Counter { |
从Java5开始,jdk的并发包里提供了原子类,包括以原子的方式将当前值自增1和自减1
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候将版本号加1,那么A->B->A就会变成1A->2B->3A。从Java5开始,JDK的Atomic包里提供了一个类AtomicStampedReference
来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的新值。
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器的pause指令,那么效率还有一定的提升。pause指令有两个作用:
锁机制保证了只有获得锁的线程能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候就是用循环CAS的方式来获取锁,当它推出同步块的时候使用循环CAS释放锁。
]]>@Resource、@Inject和@Autowired是在Java开发中都会经常用到的注解,这些注解为类提供了一种声明性的方式来解决依赖关系
1 |
|
与之对应的命令式实例化:
1 | AnotherClass object = new AnotherClass(); |
其中两个来自java扩展包:javax.annotation.Resource
和javax.inject.Inject
。@Autowired
注解属于org.springframework.beans.factory.annotation
包。
这些注解都可以通过字段注入或者setter注入来解决依赖关系,下面会分别比较一下它们之间的区别以及执行路径。
@Resource注释是JSR-250注释集合的一部分,并与 Jakarta EE 一起打包。此注解具有以下执行路径,按优先级列出:
这些执行路径适用于 setter 和 field 注入。
我们可以通过使用@Resource注释来注释实例变量来通过字段注入来解决依赖关系。
我们将使用以下测试来演示按名称匹配字段注入:
1 |
|
看一下代码。在FieldResourceInjectionTest测试中,在第 7 行,我们将 bean 名称作为属性值传递给*@Resource*注释来按名称解析依项:
1 |
|
此配置将使用按名称匹配执行路径解析依赖关系。我们必须在ApplicationContextTestResourceNameType应用程序上下文中定义 bean namedFile
。
注意 bean id 和对应的引用属性值必须匹配:
1 |
|
如果我们未能在应用程序上下文中定义 bean,会抛出NoSuchBeanDefinitionException
。我们可以通过更改ApplicationContextTestResourceNameType
应用程序上下文中传递给@Bean
注解的属性值,或者更改FieldResourceInjectionTest测试中传递给*@Resource*注解的属性值来证明这一点。
为了演示按类型匹配的执行路径,我们只删除FieldResourceInjectionTest
测试第 7 行的属性值:
1 |
|
然后我们再次运行测试。
测试仍然会通过,因为如果*@Resource*注释没有接收到 bean 名称作为属性值,Spring 框架将继续进行下一级优先级,按类型匹配,以尝试解决依赖关系。
为了演示 match-by-qualifier 执行路径,修改测试场景,以便在ApplicationContextTestResourceQualifier应用程序上下文中定义两个 bean:
1 |
|
我们使用QualifierResourceInjectionTest测试来演示逐个匹配的依赖关系解析。在这种情况下,需要将特定的 bean 依赖注入到每个引用的变量中:
1 |
|
当我们运行测试时,会抛出NoUniqueBeanDefinitionException
。这会发生,因为应用程序上下文将找到两个类型为File
的 bean 定义,并且不知道哪个 bean 应该解决依赖关系。
要解决这个问题,我们需要参考QualifierResourceInjectionTest测试的第 7 行到第 10 行:
1 |
|
我们必须添加以下代码:
1 |
使代码如下所示:
1 |
|
再次运行测试时,通过。
测试表明,即使我们在应用程序上下文中定义了多个 bean,我们也可以使用*@Qualifier*注释通过允许我们将特定的依赖项注入到一个类中来消除任何混淆。
在字段上注入依赖项时所采用的执行路径也适用于setter注入。
唯一的区别是MethodResourceInjectionTest
有一个 setter 方法:
1 |
|
我们通过注解引用变量的相应 setter 方法,通过 setter 注入来解决依赖关系。然后我们将bean依赖的名称作为属性值传递给*@Resource*注解:
1 | private File defaultFile; |
在本例中,我们将重用namedFile
bean 依赖项。bean 名称和相应的属性值必须匹配。
运行测试,通过。
为了让我们验证 match-by-name 执行路径是否解决了依赖关系,我们需要将传递给*@Resource注释的属性值更改为我们选择的值并再次运行测试。这一次,测试失败并出现NoSuchBeanDefinitionException*。
为了演示基于 setter、按类型匹配的执行,我们将使用MethodByTypeResourceTest测试:
1 |
|
运行这个测试时,通过。
为了让我们验证按类型匹配的执行路径是否解决了File依赖关系,我们需要将defaultFile变量的类类型更改为另一个类类型,如String。然后我们可以再次执行MethodByTypeResourceTest测试,这次会抛出NoSuchBeanDefinitionException 。
该异常验证是否确实使用了按类型匹配来解决文件依赖关系。NoSuchBeanDefinitionException确认引用变量名称不需要与 bean 名称匹配。相反,依赖解析取决于 bean 的类类型与引用变量的类类型匹配。
我们使用MethodByQualifierResourceTest测试来演示 match-by-qualifier 执行路径:
1 |
|
我们的测试表明,即使我们在应用程序上下文中定义了特定类型的多个 bean 实现,我们也可以使用*@Qualifier注释和@Resource*注释来解决依赖关系。
类似于基于字段的依赖注入,如果我们在一个应用上下文中定义多个bean,我们必须使用 @Qualifier 注解来指定使用哪个bean来解析依赖,否则会抛出NoUniqueBeanDefinitionException 。
@Inject注释属于JSR-330注释集合。此注解具有以下执行路径,按优先级列出:
这些执行路径适用于 setter 和 field 注入。为了让我们访问*@Inject注解,我们必须将javax.inject*库声明为依赖项。
1 | <dependency> |
我们将修改测试示例以使用另一种类型的依赖项,即ArbitraryDependency类。ArbitraryDependency类依赖仅作为一个简单的依赖,并没有其他意义:
1 |
|
这是有错误的FieldInjectTest测试:
1 |
|
与*@Resource注解首先按名称解析依赖关系不同,@* Inject注解的默认行为是按类型解析依赖关系。
这意味着即使类引用变量名称与 bean 名称不同,依赖关系仍然会被解析,前提是 bean 是在应用程序上下文中定义的。请注意以下测试中引用变量名称的方式:
1 |
|
与应用程序上下文中配置的 bean 名称不同:
1 |
|
当我们执行测试时,我们能够解决依赖关系。
如果一个特定的类类型有多个实现,并且某个类需要一个特定的 bean,该怎么办?让我们修改测试示例,使其需要另一个依赖项。
在此示例中,我们将ArbitraryDependency类(在按类型匹配示例中使用)进行子类化,以创建AnotherArbitraryDependency类:
1 | public class AnotherArbitraryDependency extends ArbitraryDependency { |
每个测试用例的目标是确保我们将每个依赖项正确地注入每个引用变量中:
1 |
|
我们可以使用FieldQualifierInjectTest测试来演示限定符匹配:
1 |
|
如果我们在应用程序上下文中有特定类的多个实现,并且FieldQualifierInjectTest测试尝试以下面列出的方式注入依赖项,则会抛出NoUniqueBeanDefinitionException
1 |
|
抛出这个异常是 Spring 框架指出某个类有多个实现的方式,它对使用哪一个感到困惑。为了阐明混淆,我们可以转到FieldQualifierInjectTest测试的第 7 行和第 10 行:
1 |
|
我们可以将所需的 bean 名称传递给*@Qualifier注释,我们将其与@Inject*注释一起使用。这就是代码块现在的样子:
1 |
|
@Qualifier注解在接收 bean 名称时要求严格匹配。我们必须确保将 bean 名称正确传递给Qualifier,否则将抛出NoUniqueBeanDefinitionException 。如果我们再次运行测试,它应该会通过。
用于演示按名称匹配的FieldByNameInjectTest测试类似于按类型匹配执行路径。唯一的区别是现在我们需要一个特定的 bean,而不是一个特定的类型。在此示例中,我们再次对ArbitraryDependency类进行子类化以生成YetAnotherArbitraryDependency类:
1 | public class YetAnotherArbitraryDependency extends ArbitraryDependency { |
为了演示按名称匹配的执行路径,我们将使用以下测试:
1 |
|
我们列出应用程序上下文:
1 |
|
如果我们运行测试,它将通过。
为了验证我们是否通过按名称匹配执行路径注入了依赖项,我们需要将传入*@Named注解的值yetAnotherFieldInjectDependency更改为我们选择的另一个名称。当我们再次运行测试时,会抛出NoSuchBeanDefinitionException* 。
@Inject注解的基于设置器的注入类似于用于基于*@Resource*设置器的注入的方法。不是把注解在变量上面,而是注解在相应的 setter 方法。基于 setter 的注入所遵循的执行路径和基于字段的依赖注入也相同。
@Autowired注解的行为类似于*@Inject注解。唯一的区别是@Autowired注解是 Spring 框架的一部分。此注解与@Inject*注解具有相同的执行路径,按优先顺序列出:
这些执行路径适用于 setter 和 field 注入。
用于演示*@Autowired按类型匹配执行路径的测试示例将类似于用于演示@Inject按类型匹配执行路径的测试。我们使用以下FieldAutowiredTest测试来演示使用@Autowired*注释的按类型匹配:
1 |
|
此测试的应用程序上下文:
1 |
|
我们使用此测试来证明按类型匹配优先于其他执行路径。注意FieldAutowiredTest测试第 8 行的引用变量名称:
1 |
|
这与应用程序上下文中的 bean 名称不同:
1 |
|
当我们运行测试时,它应该会通过。
为了确认依赖确实是使用 match-by-type 执行路径解决的,我们需要更改fieldDependency引用变量的类型并再次运行测试。这一次,FieldAutowiredTest测试将失败,并引发NoSuchBeanDefinitionException。这验证了我们使用了按类型匹配来解决依赖关系。
如果我们遇到在应用程序上下文中定义了多个 bean 实现的情况怎么办:
1 |
|
如果我们执行以下FieldQualifierAutowiredTest测试,则会抛出NoUniqueBeanDefinitionException :
1 |
|
异常是由于应用程序上下文中定义的两个 bean 引起的歧义。Spring 框架不知道哪个 bean 依赖项应该自动装配到哪个引用变量。我们可以通过在FieldQualifierAutowiredTest测试的第 7 行和第 10 行添加*@Qualifier*注释来解决此问题:
1 |
|
使代码块如下所示:
1 |
|
当我们再次运行测试时,它将会通过。
我们使用相同的测试场景来演示使用*@Autowired*注释注入字段依赖项的按名称匹配执行路径。
1 |
|
传递到*@Component注释的属性值autowiredFieldDependency告诉 Spring框架ArbitraryDependency类是一个名为autowiredFieldDependency的组件。为了让@Autowired注解通过名称解析依赖,组件名称必须与FieldAutowiredNameTest*测试中定义的字段名称相对应;请参考第7行:
1 |
|
当我们运行FieldAutowiredNameTest测试时,它将会通过。
但是我们怎么知道*@Autowired注解确实调用了按名称匹配的执行路径呢?我们可以将引用变量autowiredFieldDependency*的名称更改为我们选择的另一个名称,然后再次运行测试。
这一次,测试将失败并抛出NoUniqueBeanDefinitionException。
类似的检查是将*@Component属性值autowiredFieldDependency更改为我们选择的另一个值并再次运行测试。NoUniqueBeanDefinitionException也会*被抛出。
这个异常证明如果我们使用不正确的 bean 名称,将找不到有效的 bean。这就是我们知道调用了按名称匹配执行路径的方式。
@Autowired注解的基于设置器的注入类似于为基于*@Resource*的setter注入的方法。所遵循的执行路径也和基于字段的依赖注入相同。
下面提出了应该使用哪种注解以及在什么情况下使用的问题。这些问题的答案取决于应用面临的设计场景,以及开发人员希望如何利用基于每个注释的默认执行路径的多态性。
如果设计使得应用程序行为基于接口或抽象类的实现,并且这些行为在整个应用程序中使用,那么我们可以使用*@Inject或@Autowired*注解。
这种方法的好处是,当我们升级应用程序或应用补丁来修复错误时,可以更换实现,而对整体应用程序行为的影响最小。在这种情况下,默认执行路径是按类型匹配。
如果设计使得应用程序具有复杂的行为,每个行为都基于不同的接口或者抽象类,并且每个实现的用法在应用程序中有所不同,那么我们可以使用*@Resource*注解。在这种情况下,默认执行路径是按名称匹配。
如果使用 Jakarta EE 平台而不是 Spring 注入所有依赖项的设计要求,那么选择是在*@Resource注释和@Inject*注释之间进行选择。我们应该根据需要哪个默认执行路径来缩小两个注释之间的选择。
如果要求所有依赖项都由 Spring 框架处理,则唯一的选择是*@Autowired*注释。
下表总结了我们的讨论
设想 | @Resource | @Inject | @Autowired |
---|---|---|---|
通过多态性在应用程序范围内使用单例 | ✗ | ✔ | ✔ |
通过多态进行细粒度的应用程序行为配置 | ✔ | ✗ | ✗ |
依赖注入应该由 Jakarta EE 平台单独处理 | ✔ | ✔ | ✗ |
依赖注入应该由 Spring Framework 单独处理 | ✗ | ✗ | ✔ |
在本文中,我们更深入地了解每个注释的行为。了解每个注释的行为方式将有助于更好的整体应用程序设计和维护。
]]>最常见的锁,可以非常快速的实现多线程的同步操作,只需要在需要同步的方法、对象、或代码块中加入该关键字,就能保住同一时刻最多只有一个线程执行
使用synchronized修饰的代码具有原子性和可见性,在需要进程同步的程序中使用的频率非常高,可以满足一般的进程同步要求
Java实现的锁机制有很多种,并且有些锁机制性能也比synchronized高,但还是强烈推荐在多线程应用程序中使用该关键字,因为实现方便,后续工作由JVM来完成,可靠性高。只有在确定锁机制是当前多线程程序的性能瓶颈时,才考虑使用其他机制,如ReentrantLock等。
从语法上讲,Synchronized可以把任何一个非null对象作为”锁”,在HotSpot中,锁有个专门的名字:对象监视器(Object Monitor)。
锁的对象:
数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM
当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
1 | public void test() { |
查看反编译的结果
1 | ;省略 |
monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor
的对象来完成,其实wait/notify等方法也依赖于monitor
对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
可重入互斥锁,其基本行为和语义与使用同步方法和语句访问的隐式监视器锁相同,但具有扩展功能。
ReentrantLock属于上次成功锁定但尚未解锁的线程。当锁不属于另一个线程时,调用锁的线程将返回并成功获取锁。如果当前线程已经拥有锁,则该方法将立即返回。
可以使用isHeldByCurrentThread
和getHoldCount
方法进行检查。
此类的构造函数接受可选的公平性参数。如果设置为true
,则在争用情况下,锁有利于授予对等待时间最长的线程的访问权。否则,此锁不保证任何特定的访问顺序。
与使用默认设置的程序相比,使用由多个线程访问的公平锁的程序可能会显示较低的总体吞吐量,但在获得锁和保证无饥饿的时间上差异较小。然而,请注意,锁的公平性并不能保证线程调度的公平性。因此,使用公平锁的多个线程中的一个线程可能会连续多次获得公平锁,而其他活动线程则没有进展,当前也没有持有该锁。还要注意,tryLock()
方法不支持公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。
建议在调用后立即使用try
块锁定,最典型的是在构建之前/之后,例如:
1 | class X { |
需要额外注意的是:此类的序列化与内置锁的行为相同:反序列化的锁处于解锁状态,而不管其序列化时的状态如何。
最多支持同一线程的2147483647个递归锁。试图超过此限制会导致锁定方法引发错误。
计数型信号量。从概念上讲,信号量维护一组许可。如有必要,每个模块都会阻塞,直到获得许可证,然后再获取。
每次发布都会添加一个许可证,可能会释放一个阻塞收单机构。
但是,未使用实际许可对象;信号量只保留可用数字的计数,并相应地进行操作。
信号量通常用于限制可以访问某些(物理或逻辑)资源的线程数。
例如,下面是一个类,它使用信号量控制对资源的访问:
1 | class Pool { |
在获取项目之前,每个线程必须从信号量获取许可证,以确保项目可用。
当线程处理完该项后,它将返回到池中,并向信号量返回一个许可证,允许另一个线程获取该项。
调用acquire时不会保持同步锁,因为这会阻止项目返回到池中。
信号量封装了限制对池的访问所需的同步,与维护池本身一致性所需的任何同步分开。
一个初始化为1的信号量,其使用方式是它最多只有一个可用的许可证,可以用作互斥锁。这通常被称为二进制信号量,因为它只有两种状态:一个许可可用,或者零个许可可用。
以这种方式使用时,二进制信号量具有这样的属性(与许多java.util.concurrent.locks.Lock
实现不同),即“锁”可以由所有者以外的线程释放(因为信号量没有所有权的概念)。
这在某些特定的上下文中很有用,例如死锁恢复。
此类的构造函数可以选择接受公平性参数。当设置为false时,此类不保证线程获取许可的顺序。
特别是,允许bargging
,也就是说,调用acquire
的线程可以在等待的线程之前分配一个许可证:
从逻辑上讲,新线程将自己置于等待线程队列的头部。
当公平性设置为true
时,信号量保证选择调用任何acquire
方法的线程,以按照其调用这些方法的处理顺序(FIFO)获取许可。
FIFO排序必然适用于这些方法中的特定内部执行点。
因此,一个线程可以在另一个线程之前调用acquire
,但可以在另一个线程之后到达排序点,类似地,从方法返回时也可以到达排序点。还要注意,tryAcquire
方法不支持公平性设置,但将接受任何可用的许可。
通常,用于控制资源访问的信号量应该初始化为公平的,以确保没有线程因访问资源而耗尽。
当将信号量用于其他类型的同步控制时,非公平排序的吞吐量优势往往超过公平性考虑。
此类还提供了方便的方法,可以一次获取和发布多个信号量。这些方法通常比循环更有效。
但是,它们不建立任何优先顺序。例如,如果线程A调用s.acquire(3)
,线程B调用s.acquire(2)
,并且2个信号量可用,那么不能保证线程B将获得它们,除非它的acquire先到达,并且信号量s处于公平模式。
为避免线程因抛出异常而无法正常释放锁的情况发生,释放锁的操作必须在finally代码块中完成
原子类,通过JUC提供的Atomic[Integer/Boolean/…],提供了所有类型的原子操作
除了简单的set
,get
外,还包括getAndSet
, compareAndSet
, getAndIncrement
等操作
通过Unsafe
使这些方法都实现了原子性
公平锁指多个线程按照申请锁的顺序来获取锁,非公平锁就是没有顺序完全随机,所以能会造成优先级反转或者饥饿现象;**synchronized
就是非公平锁,ReentrantLock
(使用 CAS 和 AQS 实现) 通过构造参数可以决定是非公平锁还是公平锁,默认构造是非公平锁;非公平锁的吞吐量性能比公平锁大好。**
又名递归锁,指在同一个线程在外层方法获取锁的时候在进入内层方法会自动获取锁,synchronized
和 ReentrantLock
都是可重入锁,可重入锁可以在一定程度避免死锁。
独享锁是指该锁一次只能被一个线程持有,共享锁指该锁可以被多个线程持有;**synchronized
和 ReentrantLock
都是独享锁,ReadWriteLock
的读锁是共享锁,写锁是独占锁;**ReentrantLock
的独享锁和共享锁也是通过 AQS 来实现的。
其实就是独享锁、共享锁的具体说法;互斥锁实质就是 ReentrantLock
,读写锁实质就是 ReadWriteLock
。
这个分类不是具体锁的分类,而是看待并发同步的角度;悲观锁认为对于同一个数据的并发操作一定是会发生修改的(哪怕实质没修改也认为会修改),因此对于同一个数据的并发操作,悲观锁采取加锁的形式,因为悲观锁认为不加锁的操作一定有问题;乐观锁则认为对于同一个数据的并发操作是不会发生修改的,在更新数据的时候会采用不断的尝试更新,乐观锁认为不加锁的并发操作是没事的;由此可以看出悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升,悲观锁在 java 中很常见,乐观锁其实就是基于 CAS 的无锁编程,譬如 java 的原子类就是通过 CAS 自旋实现的。
实质是一种锁的设计策略,不是具体的锁,对于 ConcurrentHashMap
而言其并发的实现就是通过分段锁的形式来实现高效并发操作;当要 put 元素时并不是对整个 hashmap
加锁,而是先通过 hashcode
知道它要放在哪个分段,然后对分段进行加锁,所以多线程 put 元素时只要放在的不是同一个分段就做到了真正的并行插入,但是统计 size 时就需要获取所有的分段锁才能统计;分段锁的设计是为了细化锁的粒度。
这种分类是按照锁状态来归纳的,并且是针对 synchronized
的,java 1.6 为了减少获取锁和释放锁带来的性能问题而引入的一种状态,其状态会随着竞争情况逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后无法降为偏向锁,这种升级无法降级的策略目的就是为了提高获得锁和释放锁的效率。
其实是相对于互斥锁的概念,互斥锁线程会进入 WAITING
状态和 RUNNABLE
状态的切换,涉及上下文切换、cpu 抢占等开销,自旋锁的线程一直是 RUNNABLE
状态的,一直在那循环检测锁标志位,机制不重复,但是自旋锁加锁全程消耗 cpu,起始开销虽然低于互斥锁,但随着持锁时间加锁开销是线性增长。
synchronized
是不可中断的,Lock 是可中断的,这里的可中断建立在阻塞等待中断,运行中是无法中断的。
首先看一下Unsafe类注释
A collection of methods for performing low-level, unsafe operations. Although the class and all methods are public, use of this class is limited because only trusted code can obtain instances of it. Note: It is the responsibility of the caller to make sure arguments are checked before methods of this class are called. While some rudimentary checks are performed on the input, the checks are best effort and when performance is an overriding priority, as when methods of this class are optimized by the runtime compiler, some or all checks (if any) may be elided. Hence, the caller must not rely on the checks and corresponding exceptions!
执行低级不安全操作的方法集合。尽管该类和所有方法都是公共的,但该类的使用受到限制,因为只有受信任的代码才能获得它的实例。注意:调用方有责任确保在调用此类的方法之前检查参数。虽然对输入执行了一些基本检查,但这些检查是尽最大努力的,当性能是压倒一切的优先级时,例如当此类的方法由运行时编译器优化时,可以省略一些或所有检查(如果有)。因此,调用方不能依赖于检查和相应的异常!
可以看到两点:
下面是类中提供的获取实例的代码
1 | public static Unsafe getUnsafe() { |
可以看到,在获取实例时会检查是否是Bootstrap类,只有Bootstrap才能获取实例进行不安全的操作,因此,一般我们有两种方法获取Unsafe的实例
可以在启动时加上
1 | -Xbootclasspath/p:path |
从而在设置的类中调用
1 | Unsafe.getUnsafe(); |
获取Unsafe的实例,这种方法比较麻烦,因此接下来通过Java反射机制获取。
我们观察到Unsafe类中维护了一个theUnsafe
实例
1 | private static final Unsafe theUnsafe = new Unsafe(); |
因此通过反射可以获取到这个实例
1 | Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); |
拿到这个类的实例后我们能做什么呢?
可以观察一下这个类提供的方法
Unsafe API的大部分方法都是native实现,主要包括以下几类:
Info相关。主要返回某些低级别的内存信息
Objects相关。主要提供Object和它的域操纵方法
Class相关。主要提供Class和它的静态域操纵方法
Arrays相关。数组操纵方法
Synchronization相关。 主要提供低级别同步原语(如基于CPU的CAS (Compare-And-Swap) 原语)
Memory相关。直接内存访问方法,绕过JVM堆直接操纵本地内存
上面这种操作是这样实现的
1 | Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); |
通过Unsafe类,我们可以修改被标记为final的对象
1 | long address = unsafe.allocateMemory(4 * Integer.SIZE); |
其他的如内存拷贝,CAS操作就不一一演示了,基本概念和C++相似
]]>信号量本质上是一个非负数的计数器
给共享资源建立一个标志,表示该共享资源被占用情况
进程对信号量有两个操作:
和互斥锁类似,信号量本质也是一个全局变量。不同之处在于,互斥锁的值只有 2 个(加锁 “lock” 和解锁 “unlock”),而信号量的值可以根据实际场景的需要自行设置(取值范围为 ≥0)。更重要的是,信号量还支持做“加 1”或者 “减 1”运算,且修改值的过程以“原子操作”的方式实现。
原子操作是指当多个线程试图修改同一个信号量的值时,各线程修改值的过程不会互相干扰。例如信号量的初始值为 1,此时有 2 个线程试图对信号量做“加 1”操作,则信号量的值最终一定是 3,而不会是其它的值。反之若不以“原子操作”方式修改信号量的值,那么最终的计算结果还可能是 2(两个线程同时读取到的值为 1,各自在其基础上加 1,得到的结果即为 2)。
多线程程序中,使用信号量需遵守以下几条规则:
根据初始值的不同,信号量可以细分为 2 类,分别为二进制信号量和计数信号量:
了解什么是信号量之后,接下来教大家如何创建并使用信号量。
POSIX 标准中,信号量用 sem_t 类型的变量表示,该类型定义在<semaphore.h>
头文件中。例如,下面代码定义了名为 mySem 的信号量:
1 |
|
由此,我们就成功定义了一个 mySem 信号量。但要想使用它,还必须完成初始化操作。
sem_init() 函数专门用来初始化信号量,语法格式如下:
1 | int sem_init(sem_t *sem, int pshared, unsigned int value); |
各个参数的含义分别为:
当 sem_init() 成功完成初始化操作时,返回值为 0,否则返回 -1。
对于初始化了的信号量,我们可以借助 <semaphore.h> 头文件提供的一些函数操作它,比如:
1 | int sem_post(sem_t* sem); |
参数 sem 都表示要操作的目标信号量。各个函数的功能如下:
以上函数执行成功时,返回值均为 0 ;如果执行失败,返回值均为 -1。
前面讲过,信号量又细分为二进制信号量和计数信号量,虽然创建和使用它们的方法(函数)是相同的,但应用场景不同。
二进制信号量常用于代替互斥锁解决线程同步问题,接下来我们使用二进制信号量模拟“4 个售票员卖 10 张票”的过程:
1 |
|
假设程序编写在 thread.c 文件中,执行过程如下:
1 | [root@localhost ~]# gcc thread.c -o thread.exe -lpthread |
程序中信号量的初始值为 1,当有多个线程想执行 19~25 行代码时,第一个执行 sem_wait() 函数的线程可以继续执行,同时信号量的值会由 1 变为 0,其它线程只能等待信号量的值由 0 变为 1 后,才能继续执行。
假设某银行只开设了 2 个窗口,但有 5 个人需要办理业务。如果我们使用多线程程序模拟办理业务的过程,可以借助计数信号量实现。
1 |
|
假设程序编写在 thread.c 文件中,执行过程为:
1 | [root@localhost ~]# gcc thread.c -o thread.exe -lpthread |
程序中,sem 信号量的初始化为 2,因此该信号量属于计数信号量。借助 sem 信号量,第 14~21 行的代码段最多只能有 2 个线程同时访问。
]]>共享内存是一种用于实现进程间通信(IPC)的方法,不同进程通过访问同一块内存区域实现数据共享和交互。每个进程可以将自身的虚拟地址映射到物理内存中的特定区域,当不同进程将相同的物理内存区域与各自的虚拟地址空间关联时,这些进程就能实现通过共享内存来完成IPC。若某进程更改了共享内存区的内容,其它进程都会觉察到该区域的更改。
每个进程有自己的进程控制块和地址空间,且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存。
但是共享内存没有进程间同步与互斥机制。例如,进程A对共享内存执行写操作,在A的写入结束之前,进程B就可以从共享内存区读取数据,并无某种自动的机制来阻止进程B的读操作。一般为了实现进程同步和互斥,常常将共享内存和信号量配合使用。
系统给出了共享内存相关的一些接口,常用的主要有创建共享内存、进程关联共享内存、进程去关联共享内存、设置共享内存等,各接口列举如下,其更详细的说明可参阅资料。
1 | //创建 |
该接口用于获得一个共享内存标识符或创建一个共享内存对象并返回其标识符。key为标识共享内存的键值,size以字节为单位设定内存大小,shmflg为内存模式标志参数。
1 | //关联 |
该接口将标识符为shmid的共享内存区域对象映射到调用进程的地址空间,shmaddr指定共享内存出现在进程内存地址所在的位置,常设为NULL让内核决定合适的位置。shmflg指定操作共享内存的方式。当映射成功时,函数返回附加好的共享内存地址。
1 | //去关联 |
该接口用于断开共享内存连接,禁止本进程访问此片共享内存区域。shmaddr指连接的共享内存的起始地址,断开成功时函数返回0。
1 | //设置(常用来删除) |
该接口实现对共享内存段的控制,获取或设置其相关属性。cmd设置为IPC_RMID
时该接口删除此段共享内存。
1 |
|
在 Linux 系统(以及其他类 Unix操作系统)中,信号被用于进程间的通信。信号是一个发送到某个进程或同一进程中的特定线程的异步通知,用于通知发生的一个事件。从 1970 年贝尔实验室的 Unix 面世便有了信号的概念,而现在它已经被定义在了 POSIX 标准中。
对于在 Linux 环境进行编程的用户或系统管理员来说,较好地理解信号的概念和机制是很重要的,在某些情况下可以帮助我们更高效地编写程序。对于一个程序来说,如果每条指令都运行正常的话,它会连续地执行。但如果在程序执行时,出现了一个错误或任何异常,内核就可以使用信号来通知相应的进程。
信号同样被用于通信、同步进程和简化进程间通信,在 Linux 中,信号在处理异常和中断方面,扮演了极其重要的角色。信号巳经在没有任何较大修改的情况下被使用了将近 30 年。
当一个事件发生时,会产生一个信号,然后内核会将事件传递到接收的进程。有时,进程可以发送一个信号到其他进程。除了进程到进程的信号外,还有很多种情况,内核会产生一个信号,比如文件大小达到限额、一个 I/O 设备就绪或用户发送了一个类似于 Ctrl+C 或 Ctrl+Z 的终端中断等。
运行在用户模式下的进程会接收信号。如果接收的进程正运行在内核模式,那么信号的执行只有在该进程返回到用户模式时才会开始。
发送到非运行进程的信号一定是由内核保存,直到进程重新执行为止。休眠的进程可以是可中断的,也可以是不可中断的。如果一个在可中断休眠状态的进程(例如,等待终端输入的进程)收到了一个信号,那么内核会唤醒这个进程来处理信号。如果一个在不可中断休眠状态的进程收到了一个信号,那么内核会拖延此信号,直到该事件完成为止。
当进程收到一个信号时,可能会发生以下 3 种情况:
进程可能会忽略此信号。有些信号不能被忽略,而有些没有默认行为的信号,默认会被忽略。
进程可能会捕获此信号,并执行一个被称为信号处理器的特殊函数。
进程可能会执行信号的默认行为。例如,信号 15(SIGTERM) 的默认行为是结束进程。
当一个进程执行信号处理时,如果还有其他信号到达,那么新的信号会被阻断直到处理器返冋为止。
每个信号都有以SIG
开头的名称,并定义为唯一的正整数。在 Shell 命令行提示符 下,输入kill -l
命令,将显示所有信号的信号值和相应的信号名,类似如下所示:
1 | kill -l |
信号值被定义在文件/usr/include/bits/signum.h
中,其源文件是 /usr/src/linux/kernel/signal.c
。
在 Linux 下,可以查看 signal(7) 手册页来查阅信号名列表、信号值、默认的行为和它们是否可以被捕获。其命令如下所示:
1 | man 7 signal |
下标所列出的信号是 POSIX 标准的一部分,它们通常被缩写成不带SIG
前缀,例如,SIGHUP 通常被简单地称为 HUP。
信 号 | 默认行为 | 描 述 | 信号值 |
---|---|---|---|
SIGABRT | 生成 core 文件然后终止进程 | 这个信号告诉进程终止操作。ABRT 通常由进程本身发送,即当进程调用 abort() 函数发出一个非正常终止信号时 | 6 |
SIGALRM | 终止 | 警告时钟 | 14 |
SIGBUS | 生成 core 文件然后终止进程 | 当进程引起一个总线错误时,BUS 信号将被发送到进程。例如,访问了一部分未定义的内存对象 | 10 |
SIGCHLD | 忽略 | 当了进程结束、被中断或是在被中断之后重新恢复时,CHLD 信号会被发送到进程 | 20 |
SIGCONT | 继续进程 | CONT 信号指不操作系统重新开始先前被 STOP 或 TSTP 暂停的进程 | 19 |
SIGFPE | 生成 core 文件然后终止进程 | 当一个进程执行一个错误的算术运算时,FPE 信号会被发送到进程 | 8 |
SIGHUP | 终止 | 当进程的控制终端关闭时,HUP 信号会被发送到进程 | 1 |
SIGILL | 生成 core 文件然后终止进程 | 当一个进程尝试执行一个非法指令时,ILL 信号会被发送到进程 | 4 |
SIGINT | 终止 | 当用户想要中断进程时,INT 信号被进程的控制终端发送到进程 | 2 |
SIGKILL | 终止 | 发送到进程的 KILL 信号会使进程立即终止。KILL 信号不能被捕获或忽略 | 9 |
SIGPIPE | 终止 | 当一个进程尝试向一个没有连接到其他目标的管道写入时,PIPE 信号会被发送到进程 | 13 |
SIGQUIT | 终止 | 当用户要求进程执行 core dump 时,QUIT 信号由进程的控制终端发送到进程 | 3 |
SIGSEGV | 生成 core 文件然后终止进程 | 当进程生成了一个无效的内存引用时,SEGV 信号会被发送到进程 | 11 |
SIGSTOP | 停止进程 | STOP 信号指示操作系统停止进程的执行 | 17 |
SIGTERM | 终止 | 发送到进程的 TERM 信号用于要求进程终止 | 15 |
SIGTSTP | 停止进程 | TSTP 信号由进程的控制终端发送到进程来要求它立即终止 | 18 |
SIGTTIN | 停止进程 | 后台进程尝试读取时,TTIN 信号会被发送到进程 | 21 |
SIGTTOU | 停止进程 | 后台进程尝试输出时,TTOU 信号会被发送到进程 | 22 |
SIGUSR1 | 终止 | 发送到进程的 USR1 信号用于指示用户定义的条件 | 30 |
SIGUSR2 | 终止 | 同上 | 31 |
SIGPOLL | 终止 | 当一个异步输入/输出时间事件发生时,POLL 信号会被发送到进程 | 23 |
SIGPROF | 终止 | 当仿形计时器过期时,PROF 信号会被发送到进程 | 27 |
SIGSYS | 生成 core 文件然后终止进程 | 发生有错的系统调用时,SYS 信号会被发送到进程 | 12 |
SIGTRAP | 生成 core 文件然后终止进程 | 追踪捕获/断点捕获时,会产生 TRAP 信号。 | 5 |
SIGURG | 忽略 | 当侖一个 socket 有紧急的或是带外数据可被读取时,URG 信号会被发送到进程 | 16 |
SIGVTALRM | 终止 | 当进程使用的虚拟计时器过期时,VTALRM 信号会被发送到进程 | 26 |
SIGXCPU | 终止 | 当进程使用的 CPU 时间超出限制时,XCPU 信号会被发送到进程 | 24 |
SIGXFSZ | 生成 core 文件然后终止进程 | 当文件大小超过限制时,会产生 XFSZ 信号 | 25 |
信号的处理有三种办法、分别是:忽略、捕捉和默认动作,我们可以通过捕获程序接收到的信号,进行手动处理,而不是按照系统默认的方式来处理信号,因为系统大部分的默认处理方式都是“结束”程序,所以通过捕获信号,就能够自己编程进行信号处理。
忽略信号,⼤多数信号可以使用这个方式来处理,但是有两种信号不能被忽略(分别是SIGKILL
和SIGSTOP
)。因为他们向内核和超级用户提供了进程终止和停止的可靠方法,如果忽略了,那么这个进程就变成了没⼈能管理的的进程,显然是内核设计者不希望看到的场景
捕捉信号,需要告诉内核,用户希望如何处理某⼀种信号,说⽩了就是写⼀个信号处理函数,然后将这个函数告诉内核。当该信号产生时,由内核来调用用户自定义的函数,以此来实现某种信号的处理。
系统默认动作,对于每个信号来说,系统都对应由默认的处理动作,当发生了该信号,系统会自动执行。不过,对系统来说,⼤部分的处理方式都比较粗暴,就是直接杀死该进程。
具体的信号默认动作可以使用man 7 signal
来查看系统的具体定义。
《UNIX 环境⾼级编程(第三部)》的 P251——P256中间对于每个信号有详细的说明。
了解了信号之后,如何使用信号呢?
最常用的kill命令就是一个发送信号的工具。比如,我在后台运行了⼀个 top ⼯具,通过ps 命令可以查看他的 PID,通过
kill 9 PID
来发送了⼀个终止进程的信号来结束了 top 进程。如果查看信号编号和名称,可以发现9对应的是 9) SIGKILL,正是杀死该进程的信号。⽽以下的执行过程实际也就是执行了9号信号的默认动作——杀死进程。
信号注册函数不只⼀种方法,常用的是函数signal
信号发送函数也不止⼀个,常用的是kill 函数
在正式开始了解这两个函数之前,可以先来思考⼀下,处理中断都需要处理什么问题。
按照我们之前思路来看,可以发送的信号类型是多种多样的,每种信号的处理可能不⼀定相同,那么,我们肯定需要知道到底发生了什么信号。
另外,虽然我们知道了系统发出来的是哪种信号,但是还有⼀点也很重要,就是系统产生了⼀个信号,是由谁来响应?
如果系统通过Ctrl+C产生了⼀个 SIGINT(中断信号),显然不是所有程序同时结束,那么,信号⼀定需要有⼀个接收者。对于处理信号的程序来说,接收者就是自己。
开始的时候,先来看看入门版本的信号注册函数,他的函数原型如下:
signal 的函数原型
1 | //Defined in header <signal.h> |
根据函数原型可以看出由两部分组成,⼀个是真实处理信号的函数,另⼀个是注册函数了。
对于这个函数来说,sig 显然是信号的编号,handler 是中断函数的指针。
同样,中断函数的原型中,有⼀个参数是 int 类型,显然也是信号产生的类型,方便使用⼀个函数来处理多个信号。我们先来看看简单⼀个信号注册的代码示例吧。
1 |
|
使用Ctrl+C和Ctrl+Z可以看到这两个常用的快捷键发送的是2信号和20信号,而且程序并没有停止,说明信号已经被成功捕捉到。
通过 kill 命令发送信号之前,我们需要先查看到接收者,通过 ps 命令查看了之前所写的程序的 PID,通过 kill 函数来发送。对于已注册的信号,使用 kill 发送都可以正常接收到,但是如果发送了未注册的信号,则会使得应用程序终止进程。
那么,已经可以设置信号处理函数了,信号的处理还有两种状态,分别是默认处理和忽略,这两种设置很简单,只需要将 handler 设置为
SIG_IGN(忽略信号)或 SIG_DFL(默认动作)即可。在此还有两个问题需要说明⼀下:
当执行⼀个程序时,所有信号的状态都是系统默认或者忽略状态的。除非是 调用exec进程忽略了某些信号。exec 函数将原先设置为要捕捉的信号都更改为默认动作,其他信号的状态则不会改变 。
当⼀个进程调动了 fork 函数,那么子进程会继承父进程的信号处理方式。
入门版的信号注册还是比较简单的,只需要⼀句注册和⼀个处理函数即可,那么,接下来看看,如何发送信号吧。
kill 的函数原型
1 |
|
正如我之前所说的,信号的处理需要有接受者,显然发送者必须要知道发给谁,根据 kill 函数的远行可以看到,pid 就是接受者的 pid,sig 则是发送的信号的类型。从原型来看,发送信号要比接受信号还要简单些,那么我们直接上代码吧
1 |
|
接收信号的结果
总结⼀下:
根据以上的结果可看到,基本可以实现了信号的发送,虽然不能直接发送信号名称,但是通过信号的编号,可以正常的给程序发送信号了,也是初步实现了信号的发送流程。
关于 kill 函数,还有⼀点需要额外说明,上⾯的程序限定了 pid 必须为⼤于0的正整数,其实 kill 函数传入的 pid 可以是小于等于0的整数。
pid > 0:将发送个该 pid 的进程
pid == 0:将会把信号发送给与发送进程属于同⼀进程组的所有进程,并且发送进程具有权限想这些进程发送信号。pid < 0:将信号发送给进程组ID 为 pid 的绝对值得,并且发送进程具有权限向其发送信号的所有进程
pid == -1:将该信号发送给发送进程的有权限向他发送信号的所有进程。(不包括系统进程集中的进程)
关于信号,还有更多的话题,比如,信号是否都能够准确的送达到⽬标进程呢?答案其实是不⼀定,那么这就有了可靠信号和不可靠信号
]]>~/.vimrc
即可1 | setlocal noswapfile " 不要生成swap文件 |
1 | a++:a先创建自身的一个副本,然后a自增1,最后返回副本的值 |
效率问题:
1.在内建数据类型时(即自增表示式的结果没有被使用,只是简单的用于递增操作),这时这两个表达式的效率是相同的。
2.在自定义数据类型时(主要指有类的情况),由于++a可以返回对象的引用,而a++一定要是返回对象的值(因为局部对象不能返回引用)。可想而知引用的开销当然比直接对对象进行操作要效率高很多,节省很多开销。
前置++和后置++存在本质上的区别:
前置++ 不会产生临时对象
后置++ 在返回时有一个临时对象的创建
在前置++和后置++ 效果相同的时候,最好使用前置++
垃圾收集(GC)并非Java所独创,1960年诞生于MIT的Lisp是第一个开始使用内存动态分配和垃圾收集技术的语言。其作者思考过垃圾收集需要完成的三件事情:
经过半个世纪的发展,现在内存动态分配和垃圾收集技术已经相当成熟,为什么还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,就必须对这些”自动化“技术实施必要的监控和调节。
Java内存运行时区域分为
程序计数器
Java虚拟机栈
本地方法栈
Java堆
方法区
运行时常量池
直接内存
其中程序计数器、虚拟机栈和本地方法栈随着线程而生,随线程而灭,每一个栈帧中分配多少内存基本上是在类结构上就已知的,因此这几个区域的内存分配和回收都有确定性。
而Java堆和方法区有何很高的不确定性:一个接口的实现类需要的内存可能不一样,一个方法所执行的不同条件分支需要的内存也可能不一样,只有运行时才知道会创建哪些对象,创建多少对象,这部分的分配和回收是动态的。垃圾收集器所关注的就是这部分内存如何管理。
堆里面存放着几乎所有对象实例,垃圾收集器在对堆进行回收前,第一件事情就是确定哪些对象”活着“,哪些已经”死去“。从这个角度出发,垃圾收集算法可以划分为引用计数式垃圾收集和追踪式垃圾收集两大类,这两类也被称为直接垃圾收集和间接垃圾收集,Java中所有垃圾回收算法均属于追踪式垃圾收集。
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论进行设计,一般把Java堆划分为新生代和老年代两个区域。在新生代,每次垃圾收集都有大批对象死去,每次回收后存活的少量对象,将会逐步晋升到老年代存放。
最早出现也是最基础的垃圾收集算法时标记-清除算法。算法分为”标记“和”清除“两个阶段:首先标记处所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来使用。
之所以说是最基础的收集算法,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改造而得到的。
它的主要缺点有两个:
为了解决标记-清除算法面对大量可回收对象执行效率地的问题,提出了称为”半区复制“的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
当这一块的内存用完了,就将存活的对象复制到零另外一块上面,再把使用过的空间一次清理掉。如果内存中多数对象都是存活的,这种算法会产生大量的内存复制开销,但是对于多数对象都是可回收的情况,仅需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,也不用考虑空间碎片的复杂情况,只需要移动堆顶指针,按顺序分配即可。
这样实现简单高效,不过代价是将可用内存缩小为了原来的一半,空间浪费太多。
标记-复制算法在对象存活率较高时需要进行较多的复制操作,效率会变低,更关键的是需要浪费一般的空间,所以老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,提出了标记-整理算法,其标记过程和标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
Serial收集器是最基础、历史最悠久的收集器,在JDK1.3.1之前是HotSpot虚拟机新生代收集器的唯一选择。顾名思义,这是一个单线程收集器,它的”单线程“不仅仅说明它只会使用一个处理器或者一条收集线程完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束,”Stop The World“。
事实上,迄今为止,Serial收集器依然是HotSpot运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方就是它简单而高效,对于内存资源受限的环境,它是所有收集器里额外消耗最小的;对于单核处理器或者处理器核心较少的环境来说,Serial收集器没有线程交互的开销,可以获得最高的单线程收集效率。对于一般几十兆到一两百兆的新生代来说,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百毫秒以内,只要不是频繁发生收集,这点停顿时间对于许多用户来说完全可以接受。
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集以外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则,回收策略等都与Serial收集器完全一致,在实现上两种收集器也共用了相当多的代码。
Parallel Scavenge收集器是一款新生代收集器,同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器,基本上和ParNew非常相似。
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。也就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义是供客户端模式下的HotSpot使用。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现。这个收集器直到JDK6才开始提供,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器如CMS无法与他配合工作。
直到Parallel Old收集器出现后,吞吐量优先收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理强资源较为稀缺的场合,都可以考虑Parallel Scavenge加Parallel Old收集器这个组合。
CMS(Concurrent Mark Sweep) 收集器是一种获取最短回收停顿时间为目标的收集器,目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务器上,这种应用通常较为关注服务的相应速度,系统系统缩短停顿时间,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于标记-清除算法实现的,它的运行过程相对于前面几种收集器要更复杂一些,整个过程分为四步:
其中初始标记、重新标记这两个步骤仍然需要”Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程消耗时间长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常比初始标记长一些,但也远远小于并发标记阶段是时间;最后是并发求清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个线程也可以与用户线程同时并发。
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集,低停顿。CMS是HotSpot追求第停顿的第一次成功尝试,但是它还远远达不到完美的程度,至少有以下三个明显的缺点:
G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。从JDK8 Update40之后,G1才被Oracle官方称为”全功能的垃圾收集器“(Fully-Featured Garbage Collector)。
G1是一款主要面向服务端应用的垃圾收集器。HotSpot团队最初赋予它的期望是未来可以替换掉JDK5中的CMD收集器。
G1收集器出现之前的所有其他收集器,垃圾收集的目标范围都是基于分代思想进行回收,而G1可以面向堆中任何部分来组成回收集进行回收,衡量标准不是它属于哪个分代,而是那块内存中皴法垃圾中最多,回收收益最大,这就是G1的mixed gc模式。
]]>