diff --git a/2020/08/18/install-Docker/index.html b/2020/08/18/install-Docker/index.html index c89850cc..a7afbcb7 100644 --- a/2020/08/18/install-Docker/index.html +++ b/2020/08/18/install-Docker/index.html @@ -27,7 +27,7 @@ - + @@ -229,7 +229,7 @@
Docker
的过程用于备忘,主要在新建虚拟机或重装云服务器系统时使用。
+
+
+官方文档:Install Docker Engine on Ubuntu
+for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done |
apt
仓库Add Docker's official GPG key: |
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
hello-world
镜像验证安装成功sudo docker run hello-world |
官方文档:Linux post-installation steps for Docker Engine
+docker
群组sudo groupadd docker |
docker
群组sudo usermod -aG docker $USER |
newgrp docker |
sudo
执行 docker
命令docker run hello-world |
Docker
sudo systemctl enable docker.service |
sudo systemctl disable docker.service |
Docker 命令行参考
深入探究docker attach的退出方式
Docker
的过程用于备忘,主要在新建虚拟机或重装云服务器系统时使用。
-
-
-官方文档:Install Docker Engine on Ubuntu
-for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done |
apt
仓库Add Docker's official GPG key: |
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
hello-world
镜像验证安装成功sudo docker run hello-world |
官方文档:Linux post-installation steps for Docker Engine
-docker
群组sudo groupadd docker |
docker
群组sudo usermod -aG docker $USER |
newgrp docker |
sudo
执行 docker
命令docker run hello-world |
Docker
sudo systemctl enable docker.service |
sudo systemctl disable docker.service |
iOS
和 macOS
上安装和配置 OpenVPN
客户端,主要介绍如何编写客户端配置文件 client.ovpn
并导入。
-
-
---客户端配置文件模板
-client.ovpn
以及ca.crt
(ca
),client.crt
(cert
),client.key
(key
)等文件均在安装OpenVPN
服务端时获得。
客户端提供了两种方式导入配置文件:
-URL
,建议 URL
仅限在私有网络内访问。OneDrive
共享到 iPhone
。对于客户端配置而言,iOS
的困难点在于其文件系统封闭,ca.crt
(ca
),client.crt
(cert
),client.key
(key
)不能放置到指定位置。因此配置文件分为两种形式:
CA
根证书 ca.crt
,客户端证书 client.crt
,客户端密钥 client.key
的内容复制粘贴到 client.ovpn
中,形成一个联合配置文件。这种方式简单方便,推荐!。openssl
将 CA
根证书 ca.crt
,客户端证书 client.crt
,客户端密钥 client.key
转换为 PKCS#12
文件,先导入 client.ovpn12
再导入 client.ovpn
。不推荐的原因在于本人导入失败,最终放弃。C:\Program Files\OpenVPN\sample-config
复制客户端配置文件模板 client.ovpn
,修改配置remote your-server 1194
中的地址和端口替换成你的 OpenVPN
服务端对外的地址和端口ca ca.crt
,cert client.crt
,key client.key
,tls-auth ta.key 1
注释掉,再将各自文件中的内容以类 XML
的形式粘贴到 client.ovpn
中remote your-server 1194 |
openssl
命令将客户端的证书和密钥文件转换为 PKCS#12
形式的文件。该命令会提示 Enter Export Password
,可以为空,但为了安全建议设置密码。openssl pkcs12 -export -in cert -inkey key -certfile ca -name MyClient -out client.ovpn12 |
iOS
中导入 PKCS#12
文件到 Keychain
中时只导入了客户端证书和密钥,CA
根证书并没有导入,client.ovpn
文件中必须要保留 CA
根证书的配置。ca ca.crt |
<ca> |
client.ovpn12
(需要输入转换时的密码),再导入 client.ovpn
。--但是我失败了……导入
-client.ovpn12
时密码一直错误,没有解决。推荐使用第一种方式。
本人的设置如下,仅供参考:
-OpenVPN
服务端配置的端口号为默认的 1194
。NAT
设置中,配置一个自定义的高位端口号如 49999
作为对外端口号映射到 Windows 10
主机的 1194
端口号。这既是为了安全,也是为了避免不必要的检测风险(有没有用我也不知道啊 =_= )。IP
是动态的,一旦 IP
发生变化,就需要修改配置文件。因此使用 ddns-go
配合 Cloudflare
实现动态域名解析。Windows 10
上安装和配置 OpenVPN
服务端。
-
-
-Windows 10
专业版OpenVPN 2.6.4
IP
Windows 64-bit MSI installer
。本次安装的版本为 OpenVPN 2.6.4
。Customize
而不要选择 Install Now
。OpenVPN -> OpenVPN Service -> Entire feature will be installed on local hard drive
;OpenSSL Utilities -> EasyRSA 3 Certificate Management Scripts -> Entire feature will be installed on local hard drive
;OpenVPN TAP-Windows6
和 OpenVPN Wintun
。Windows 10
终端程序。OpenVPN
默认安装目录中的 easy-rsa
目录。cd 'C:\Program Files\OpenVPN\easy-rsa' |
Easy-RSA 3 Shell
。.\EasyRSA-Start.bat |
pki
。./easyrsa init-pki |
CA
)密钥,CA
根证书文件将在后续用于对其他证书和密钥进行签名。该命令要求输入 Common Name
,输入主机名即可。创建的 ca.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki
中,ca.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-ca nopass |
server.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\issued
中,server.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-server-full server nopass |
client.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\issued
中,client.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-client-full client nopass |
Diffie-Hellman
参数./easyrsa gen-dh |
C:\Program Files\OpenVPN\sample-config
复制服务端配置文件模板 server.ovpn
到目录 C:\Program Files\OpenVPN\config
中,修改以下配置:端口号按需修改,默认为 1194
,需要保证 OpenVPN
的网络流量可以通过防火墙,设置 Windows 10 Defender
允许 OpenVPN
通过即可。dh2048.pem
修改为生成的文件名 dh.pem
。取消注释 duplicate-cn
,让多个客户端使用同一个客户端证书。注释掉 tls-auth ta.key 0
。port 1194 |
ca.crt
,dh.pem
,server.crt
和 server.key
到目录 C:\Program Files\OpenVPN\config
中。启动 OpenVPN
,点击连接,系统提示分配 IP
为 10.8.0.1
。按配置,每次 OpenVPN server
都将为自己分配 10.8.0.1
。
OpenVPN
访问家庭内网。
-
-
-
-IP
。PPPoE
),路由器局域网为 192.168.3.0/24
。Windows 10
主机,在路由器局域网上的 IP
192.168.3.120
。Windows 10
主机上运行 Vmware
虚拟机,网络采用 NAT
模式。子网为 192.168.46.0/24
。运行了 Linux
主机,IP
192.168.46.128
。Windows 10
主机在子网中的 IP
为 192.168.3.1
。子网 | -Windows 10 主机 | -Linux 主机 | -客户端 | -
---|---|---|---|
路由器 192.168.3.0/24 | -192.168.3.120 | -- | -- | -
Vmware NAT 192.168.46.0/24 | -192.168.46.1 | -192.168.46.128 | -- | -
VPN 10.8.0.0/24 | -10.8.0.1 | -- | -10.8.0.6 | -
Windows 10
主机和虚拟机上的 Linux
主机。NAT
功能直接将局域网上的设备映射到公网。一是为了安全,二是为了避免运营商审查。ZeroTier
将所需设备组建在一个局域网当中作为备选方案。IP
以及上行带宽尝尝鲜。Linux
主机,而不是通过 Vmware
的 NAT
映射。否则每次新增服务都要设置 NAT
,修改 Windows Defender
的端口暴露规则。见 在 Windows 10 上安装 OpenVPN 服务器。
-见 在 iOS 和 macOS 上安装 OpenVPN 客户端。
-如果在所需的每一个设备上都安装 OpenVPN
,将它们连接在 VPN
的子网 10.8.0.0/24
中,也是可以满足需求的,但是这样略有些麻烦。
在 server.ovpn
配置文件中新增一行配置,这个配置的意思是将该路由配置统一推送给客户端,让它们可以访问服务端的其他私有子网。相当于将服务端的其他私有子网的情况告知客户端,这样客户端就知道发往 192.168.46.128
的 Packet
是发向哪里的。
push "route 192.168.46.0 255.255.255.0" |
Win + R
输入 regedit
打开注册表。HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
,修改 IPEnableRouter
为 1
。在终端中输入命令,这是为了让 OpenVPN
服务端的其他私有子网上的设备知道来自 10.8.0.0/24
的 Packet
应该路由回 OpenVPN
服务端。
route add -net 10.8.0.0/24 gw 192.168.46.1 |
透过openvpn来访问内网资源
OpenVPN 路由详解
OpenVPN中的另一个路由问题 - 在VPN上无法访问本地计算机
openvpn添加本地路由表
windows开启路由转发
linux route命令的使用详解
Windows命令行route命令使用图解
有时候,我们需要在终端通过执行命令的方式访问网络和下载资源,比如使用 wget
和 curl
。
这一类软件都是可以通过为 Shell 设置环境变量的方式来设置代理,涉及到的环境变量有 http_proxy
、https_proxy
和 no_proxy
。
仅为当前会话设置,执行命令:
export http_proxy=http://proxyAddress:port |
永久设置代理,在设置 Shell 环境变量的脚本中(不同 Shell 的配置文件不同,比如 ~/.bashrc
或 ~/.zshrc
)添加:
export http_proxy=http://proxyAddress:port |
重新启动一个会话或者执行命令 source ~/.bashrc
使其在当前会话立即生效。
在搜索过程中发现还可以在 wget
的配置文件 ~/.wgetrc
中添加:
use_proxy = on |
如果你以为为终端设置代理后 docker 就会使用代理,那你就错了。在从官方的镜像仓库 pull 镜像反复出错后并收到类似 Error response from daemon: Get "https://registry-1.docker.io/v2/": read tcp 192.168.3.140:59460->44.205.64.79:443: read: connection reset by peer
这样的报错信息后,我才开始怀疑我并没有真正给 docker 设置好代理。
在执行 docker pull
命令时,实际上命令是由守护进程 docker daemon
执行的。
如果你的 docker daemon
是通过 systemd
管理的,那么你可以通过设置 docker.service
服务的环境变量来设置代理。
执行命令查看 docker.service
信息,得知配置文件位置 /lib/systemd/system/docker.service
。
systemctl status docker.service |
在 docker.service
的 [Service]
模块添加:
Environment=HTTP_PROXY=http://proxyAddress:port |
重新加载配置文件并重启服务:
-systemctl daemon-reload |
Windows 10
上安装和配置 OpenVPN
服务端。
+
+
+Windows 10
专业版OpenVPN 2.6.4
IP
Windows 64-bit MSI installer
。本次安装的版本为 OpenVPN 2.6.4
。Customize
而不要选择 Install Now
。OpenVPN -> OpenVPN Service -> Entire feature will be installed on local hard drive
;OpenSSL Utilities -> EasyRSA 3 Certificate Management Scripts -> Entire feature will be installed on local hard drive
;OpenVPN TAP-Windows6
和 OpenVPN Wintun
。Windows 10
终端程序。OpenVPN
默认安装目录中的 easy-rsa
目录。cd 'C:\Program Files\OpenVPN\easy-rsa' |
Easy-RSA 3 Shell
。.\EasyRSA-Start.bat |
pki
。./easyrsa init-pki |
CA
)密钥,CA
根证书文件将在后续用于对其他证书和密钥进行签名。该命令要求输入 Common Name
,输入主机名即可。创建的 ca.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki
中,ca.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-ca nopass |
server.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\issued
中,server.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-server-full server nopass |
client.crt
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\issued
中,client.key
保存在目录 C:\Program Files\OpenVPN\easy-rsa\pki\private
中。./easyrsa build-client-full client nopass |
Diffie-Hellman
参数./easyrsa gen-dh |
C:\Program Files\OpenVPN\sample-config
复制服务端配置文件模板 server.ovpn
到目录 C:\Program Files\OpenVPN\config
中,修改以下配置:端口号按需修改,默认为 1194
,需要保证 OpenVPN
的网络流量可以通过防火墙,设置 Windows 10 Defender
允许 OpenVPN
通过即可。dh2048.pem
修改为生成的文件名 dh.pem
。取消注释 duplicate-cn
,让多个客户端使用同一个客户端证书。注释掉 tls-auth ta.key 0
。port 1194 |
ca.crt
,dh.pem
,server.crt
和 server.key
到目录 C:\Program Files\OpenVPN\config
中。启动 OpenVPN
,点击连接,系统提示分配 IP
为 10.8.0.1
。按配置,每次 OpenVPN server
都将为自己分配 10.8.0.1
。
iOS
和 macOS
上安装和配置 OpenVPN
客户端,主要介绍如何编写客户端配置文件 client.ovpn
并导入。
+
+
+++客户端配置文件模板
+client.ovpn
以及ca.crt
(ca
),client.crt
(cert
),client.key
(key
)等文件均在安装OpenVPN
服务端时获得。
客户端提供了两种方式导入配置文件:
+URL
,建议 URL
仅限在私有网络内访问。OneDrive
共享到 iPhone
。对于客户端配置而言,iOS
的困难点在于其文件系统封闭,ca.crt
(ca
),client.crt
(cert
),client.key
(key
)不能放置到指定位置。因此配置文件分为两种形式:
CA
根证书 ca.crt
,客户端证书 client.crt
,客户端密钥 client.key
的内容复制粘贴到 client.ovpn
中,形成一个联合配置文件。这种方式简单方便,推荐!。openssl
将 CA
根证书 ca.crt
,客户端证书 client.crt
,客户端密钥 client.key
转换为 PKCS#12
文件,先导入 client.ovpn12
再导入 client.ovpn
。不推荐的原因在于本人导入失败,最终放弃。C:\Program Files\OpenVPN\sample-config
复制客户端配置文件模板 client.ovpn
,修改配置remote your-server 1194
中的地址和端口替换成你的 OpenVPN
服务端对外的地址和端口ca ca.crt
,cert client.crt
,key client.key
,tls-auth ta.key 1
注释掉,再将各自文件中的内容以类 XML
的形式粘贴到 client.ovpn
中remote your-server 1194 |
openssl
命令将客户端的证书和密钥文件转换为 PKCS#12
形式的文件。该命令会提示 Enter Export Password
,可以为空,但为了安全建议设置密码。openssl pkcs12 -export -in cert -inkey key -certfile ca -name MyClient -out client.ovpn12 |
iOS
中导入 PKCS#12
文件到 Keychain
中时只导入了客户端证书和密钥,CA
根证书并没有导入,client.ovpn
文件中必须要保留 CA
根证书的配置。ca ca.crt |
<ca> |
client.ovpn12
(需要输入转换时的密码),再导入 client.ovpn
。++但是我失败了……导入
+client.ovpn12
时密码一直错误,没有解决。推荐使用第一种方式。
本人的设置如下,仅供参考:
+OpenVPN
服务端配置的端口号为默认的 1194
。NAT
设置中,配置一个自定义的高位端口号如 49999
作为对外端口号映射到 Windows 10
主机的 1194
端口号。这既是为了安全,也是为了避免不必要的检测风险(有没有用我也不知道啊 =_= )。IP
是动态的,一旦 IP
发生变化,就需要修改配置文件。因此使用 ddns-go
配合 Cloudflare
实现动态域名解析。OpenVPN
访问家庭内网。
+
+
+
+IP
。PPPoE
),路由器局域网为 192.168.3.0/24
。Windows 10
主机,在路由器局域网上的 IP
192.168.3.120
。Windows 10
主机上运行 Vmware
虚拟机,网络采用 NAT
模式。子网为 192.168.46.0/24
。运行了 Linux
主机,IP
192.168.46.128
。Windows 10
主机在子网中的 IP
为 192.168.3.1
。子网 | +Windows 10 主机 | +Linux 主机 | +客户端 | +
---|---|---|---|
路由器 192.168.3.0/24 | +192.168.3.120 | +- | +- | +
Vmware NAT 192.168.46.0/24 | +192.168.46.1 | +192.168.46.128 | +- | +
VPN 10.8.0.0/24 | +10.8.0.1 | +- | +10.8.0.6 | +
Windows 10
主机和虚拟机上的 Linux
主机。NAT
功能直接将局域网上的设备映射到公网。一是为了安全,二是为了避免运营商审查。ZeroTier
将所需设备组建在一个局域网当中作为备选方案。IP
以及上行带宽尝尝鲜。Linux
主机,而不是通过 Vmware
的 NAT
映射。否则每次新增服务都要设置 NAT
,修改 Windows Defender
的端口暴露规则。见 在 Windows 10 上安装 OpenVPN 服务器。
+见 在 iOS 和 macOS 上安装 OpenVPN 客户端。
+如果在所需的每一个设备上都安装 OpenVPN
,将它们连接在 VPN
的子网 10.8.0.0/24
中,也是可以满足需求的,但是这样略有些麻烦。
在 server.ovpn
配置文件中新增一行配置,这个配置的意思是将该路由配置统一推送给客户端,让它们可以访问服务端的其他私有子网。相当于将服务端的其他私有子网的情况告知客户端,这样客户端就知道发往 192.168.46.128
的 Packet
是发向哪里的。
push "route 192.168.46.0 255.255.255.0" |
Win + R
输入 regedit
打开注册表。HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
,修改 IPEnableRouter
为 1
。在终端中输入命令,这是为了让 OpenVPN
服务端的其他私有子网上的设备知道来自 10.8.0.0/24
的 Packet
应该路由回 OpenVPN
服务端。
route add -net 10.8.0.0/24 gw 192.168.46.1 |
透过openvpn来访问内网资源
OpenVPN 路由详解
OpenVPN中的另一个路由问题 - 在VPN上无法访问本地计算机
openvpn添加本地路由表
windows开启路由转发
linux route命令的使用详解
Windows命令行route命令使用图解
有时候,我们需要在终端通过执行命令的方式访问网络和下载资源,比如使用 wget
和 curl
。
这一类软件都是可以通过为 Shell 设置环境变量的方式来设置代理,涉及到的环境变量有 http_proxy
、https_proxy
和 no_proxy
。
仅为当前会话设置,执行命令:
export http_proxy=http://proxyAddress:port |
永久设置代理,在设置 Shell 环境变量的脚本中(不同 Shell 的配置文件不同,比如 ~/.bashrc
或 ~/.zshrc
)添加:
export http_proxy=http://proxyAddress:port |
重新启动一个会话或者执行命令 source ~/.bashrc
使其在当前会话立即生效。
在搜索过程中发现还可以在 wget
的配置文件 ~/.wgetrc
中添加:
use_proxy = on |
如果你以为为终端设置代理后 docker 就会使用代理,那你就错了。在从官方的镜像仓库 pull 镜像反复出错后并收到类似 Error response from daemon: Get "https://registry-1.docker.io/v2/": read tcp 192.168.3.140:59460->44.205.64.79:443: read: connection reset by peer
这样的报错信息后,我才开始怀疑我并没有真正给 docker 设置好代理。
在执行 docker pull
命令时,实际上命令是由守护进程 docker daemon
执行的。
如果你的 docker daemon
是通过 systemd
管理的,那么你可以通过设置 docker.service
服务的环境变量来设置代理。
执行命令查看 docker.service
信息,得知配置文件位置 /lib/systemd/system/docker.service
。
systemctl status docker.service |
在 docker.service
的 [Service]
模块添加:
Environment=HTTP_PROXY=http://proxyAddress:port |
重新加载配置文件并重启服务:
+systemctl daemon-reload |
还可以修改 dockerd
配置文件,添加:
export http_proxy="http://proxyAddress:port" |
VMware
安装 Ubuntu server 20.04
,注意到实际文件系统的总空间大小仅占设置的虚拟磁盘空间大小的一半左右。本文介绍了如何解决该问题。
+
+
+++最近在本地测试
+Kubesphere
和Minikube
,使用Ubuntu server 20.04
搭建了多个虚拟机,磁盘空间紧张。注意到在安装后,实际文件系统的总空间大小仅占设置的虚拟磁盘空间大小的一半左右。如果Ubuntu server 20.04
安装时使用默认的LVM
选项,就会出现这种情况。
df -h
命令显示文件系统的总空间和可用空间信息。分配了 40G
磁盘空间,可用仅 19G
。df -h |
sudo vgdisplay
命令查看发现 Free PE / Size
还有 19G
。sudo vgdisplay |
sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
调整逻辑卷的大小。sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv |
sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
调整文件系统的大小。sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv |
df -h
命令再次查看,确认文件系统的总空间大小调整为 38G
。df -h |
ubuntu20.04 server 安装后磁盘空间只有一半的处理
Ubuntu Server 20.04.1 LTS, not all disk space was allocated during installation?
当 Java
程序启动的时候,Java
虚拟机会调用 java.lang.ClassLoader#loadClass(java.lang.String)
加载 main
方法所在的类。
public Class<?> loadClass(String name) throws ClassNotFoundException { |
public void refresh() throws BeansException, IllegalStateException { |
根据注释可知,此方法加载具有指定二进制名称的类,它由 Java
虚拟机调用来解析类引用,调用它等同于调用 loadClass(name, false)
。
protected Class<?> loadClass(String name, boolean resolve) |
准备此 context 以供刷新,设置其启动日期和活动标志以及执行属性源的初始化。
+根据注释可知,java.lang.ClassLoader#loadClass(java.lang.String, boolean)
同样是加载“具有指定二进制名称的类”,此方法的实现按以下顺序搜索类:
findLoadedClass(String)
以检查该类是否已加载。loadClass
方法。如果父·类加载器为空,则使用虚拟机内置的类加载器。findClass(String)
方法来查找该类。如果使用上述步骤找到了该类(找到并定义类),并且解析标志为 true
,则此方法将对生成的 Class
对象调用 resolveClass(Class)
方法。鼓励 ClassLoader
的子类重写 findClass(String)
,而不是此方法。除非被重写,否则此方法在整个类加载过程中以 getClassLoadingLock
方法的结果进行同步。
--编写管理资源的容器时,可以参考。
+注意:父·类加载器并非父类·类加载器(当前类加载器的父类),而是当前的类加载器的
parent
属性被赋值另外一个类加载器实例,其含义更接近于“可以委派类加载工作的另一个类加载器(一个帮忙干活的上级)”。虽然绝大多数说法中,当一个类加载器的parent
值为null
时,它的父·类加载器是引导类加载器(bootstrap class loader
),但是当看到findBootstrapClassOrNull
方法时,我有点困惑,因为我以为会看到语义类似于loadClassByBootstrapClassLoader
这样的方法名。从注释和代码的语义上看,bootstrap class loader
不像是任何一个类加载器的父·类加载器,但是从类加载的机制设计上说,它是,只是因为它并非由 Java 语言编写而成,不能实例化并赋值给parent
属性。findBootstrapClassOrNull
方法的语义更接近于:当一个类加载器的父·类加载器为null
时,将准备加载的目标类先当作启动类(Bootstrap Class
)尝试查找,如果找不到就返回null
。
protected void prepareRefresh() { |
需要加载的类可能很多很多,我们很容易想到如果可以并行地加载类就好了。显然,JDK
的编写者考虑到了这一点。
此方法返回类加载操作的锁对象。为了向后兼容,此方法的默认实现的行为如下。如果此 ClassLoader
对象注册为具备并行能力,则该方法返回与指定类名关联的专用对象。 否则,该方法返回此 ClassLoader
对象。
简单地说,如果 ClassLoader
对象注册为具备并行能力,那么一个 name
一个锁对象,已创建的锁对象保存在 ConcurrentHashMap
类型的 parallelLockMap
中,这样类加载工作可以并行;否则所有类加载工作共用一个锁对象,就是 ClassLoader
对象本身。
这个方案意味着非同名的目标类可以认为在加载时没有冲突?
protected Object getClassLoadingLock(String className) { |
告诉子类刷新内部 bean 工厂并返回,返回的实例类型为 DefaultListableBeanFactory。在这里完成了配置文件的读取,初步注册了 bean 定义。
---我大概这辈子都不会想理清楚这里面关于 XML 文件的解析过程,但是我知道可以在这里观察到 beanFactory 因为配置文件注册了哪些 bean。
-
protected ConfigurableListableBeanFactory obtainFreshBeanFactory() { |
ClassLoader
对象注册为具有并行能力”呢?AppClassLoader
中有一段 static
代码。事实上 java.lang.ClassLoader#registerAsParallelCapable
是将 ClassLoader
对象注册为具有并行能力唯一的入口。因此,所有想要注册为具有并行能力的 ClassLoader
都需要调用一次该方法。
static { |
配置 BeanFactory 以供在此 context 中使用,例如 context 的类加载器和一些后处理器,手动注册一些单例。
+java.lang.ClassLoader#registerAsParallelCapable
方法有一个注解 @CallerSensitive
,这是因为它的代码中调用的 native
方法 sun.reflect.Reflection#getCallerClass()
方法。由注释可知,当且仅当以下所有条件全部满足时才注册成功:
--ignoreDependencyInterface 和 registerResolvableDependency 在理解之后比单纯地记忆它们有趣许多。
-
protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { |
--在创建 Bean 开始前注册的单例,都属于手动注册的单例 manualSingletonNames
-
public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { |
在标准初始化后修改内部 beanFactory,默认什么都不做。
-实例化并调用所有在 context 中注册的 beanFactory 后处理器,需遵循顺序规则。具体的处理被委托给 PostProcessorRegistrationDelegate。
-protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
invokeBeanFactoryPostProcessors 方法堪比裹脚布。
-关于调用顺序的规则:
-可能新增 beanDefinition 的情况:
-public static void invokeBeanFactoryPostProcessors( |
注册拦截 bean
创建的 bean
后处理器。具体的处理被委托给 PostProcessorRegistrationDelegate。
protected void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
registerBeanPostProcessors 相比之下是一条清新的裹脚布。这里特别区分 3 种类型的 Bean 后处理器:
-ApplicationListenerDetector 既是 MergedBeanDefinitionPostProcessor,又是 DestructionAwareBeanPostProcessor,在初始化后将 listener 加入,在销毁前将 listener 移除。
-public static void registerBeanPostProcessors( |
添加 BeanPostProcessor 时
-public void addBeanPostProcessor(BeanPostProcessor beanPostProcessor) { |
初始化消息源。 如果在此 context 中未定义,则使用父级的。
-protected void initMessageSource() { |
初始化 ApplicationEventMulticaster
。 如果上下文中未定义,则使用 SimpleApplicationEventMulticaster
。可以看得出代码的结构和 initMessageSource 是类似的。
protected void initApplicationEventMulticaster() { |
可以重写模板方法来添加特定 context 的刷新工作。默认情况下什么都不做。
-获取侦听器 bean
并注册。无需初始化即可添加
protected void registerListeners() { |
添加 ApplicationListener。
---后处理器 ApplicationListenerDetector 在 processor chain 的最后,最终会将创建的代理添加为监听器。什么情况下会出现代码中预防的情况呢?
-
public void addApplicationListener(ApplicationListener<?> listener) { |
实例化所有剩余的(非惰性初始化)单例。以 context 视角,是完成内部 beanFactory 的初始化。
-几乎可以只关注最后的 beanFactory.preInstantiateSingletons()
。
protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) { |
确保所有非惰性初始化单例都已实例化,同时还要考虑 FactoryBeans
。 如果需要,通常在工厂设置结束时调用。
--先对集合进行 Copy 再迭代是很常见的处理方式,可以有效保证迭代时不受原集合影响,也不会影响到原集合。
-
|
最后一步,完成 context 刷新,比如发布相应的事件。
-protected void finishRefresh() { |
当 Java
程序启动的时候,Java
虚拟机会调用 java.lang.ClassLoader#loadClass(java.lang.String)
加载 main
方法所在的类。
public Class<?> loadClass(String name) throws ClassNotFoundException { |
根据注释可知,此方法加载具有指定二进制名称的类,它由 Java
虚拟机调用来解析类引用,调用它等同于调用 loadClass(name, false)
。
protected Class<?> loadClass(String name, boolean resolve) |
根据注释可知,java.lang.ClassLoader#loadClass(java.lang.String, boolean)
同样是加载“具有指定二进制名称的类”,此方法的实现按以下顺序搜索类:
findLoadedClass(String)
以检查该类是否已加载。loadClass
方法。如果父·类加载器为空,则使用虚拟机内置的类加载器。findClass(String)
方法来查找该类。如果使用上述步骤找到了该类(找到并定义类),并且解析标志为 true
,则此方法将对生成的 Class
对象调用 resolveClass(Class)
方法。鼓励 ClassLoader
的子类重写 findClass(String)
,而不是此方法。除非被重写,否则此方法在整个类加载过程中以 getClassLoadingLock
方法的结果进行同步。
--注意:父·类加载器并非父类·类加载器(当前类加载器的父类),而是当前的类加载器的
-parent
属性被赋值另外一个类加载器实例,其含义更接近于“可以委派类加载工作的另一个类加载器(一个帮忙干活的上级)”。虽然绝大多数说法中,当一个类加载器的parent
值为null
时,它的父·类加载器是引导类加载器(bootstrap class loader
),但是当看到findBootstrapClassOrNull
方法时,我有点困惑,因为我以为会看到语义类似于loadClassByBootstrapClassLoader
这样的方法名。从注释和代码的语义上看,bootstrap class loader
不像是任何一个类加载器的父·类加载器,但是从类加载的机制设计上说,它是,只是因为它并非由 Java 语言编写而成,不能实例化并赋值给parent
属性。findBootstrapClassOrNull
方法的语义更接近于:当一个类加载器的父·类加载器为null
时,将准备加载的目标类先当作启动类(Bootstrap Class
)尝试查找,如果找不到就返回null
。
需要加载的类可能很多很多,我们很容易想到如果可以并行地加载类就好了。显然,JDK
的编写者考虑到了这一点。
此方法返回类加载操作的锁对象。为了向后兼容,此方法的默认实现的行为如下。如果此 ClassLoader
对象注册为具备并行能力,则该方法返回与指定类名关联的专用对象。 否则,该方法返回此 ClassLoader
对象。
简单地说,如果 ClassLoader
对象注册为具备并行能力,那么一个 name
一个锁对象,已创建的锁对象保存在 ConcurrentHashMap
类型的 parallelLockMap
中,这样类加载工作可以并行;否则所有类加载工作共用一个锁对象,就是 ClassLoader
对象本身。
这个方案意味着非同名的目标类可以认为在加载时没有冲突?
protected Object getClassLoadingLock(String className) { |
ClassLoader
对象注册为具有并行能力”呢?AppClassLoader
中有一段 static
代码。事实上 java.lang.ClassLoader#registerAsParallelCapable
是将 ClassLoader
对象注册为具有并行能力唯一的入口。因此,所有想要注册为具有并行能力的 ClassLoader
都需要调用一次该方法。
static { |
java.lang.ClassLoader#registerAsParallelCapable
方法有一个注解 @CallerSensitive
,这是因为它的代码中调用的 native
方法 sun.reflect.Reflection#getCallerClass()
方法。由注释可知,当且仅当以下所有条件全部满足时才注册成功:
Object
类除外)都注册为具有并行能力。Object
类除外)都注册为具有并行能力。static
代码块中来实现。如果写在构造器方法里,并且通过单例模式保证只实例化一次可以吗?答案是不行的,后续会解释这个“注册”行为在构造器方法中是如何被使用以及为何不能写在构造器方法里。获取指定 Bean 的入口方法是 getBean,在 Spring 上下文刷新过程中,就依次调用 AbstractBeanFactory#getBean(java.lang.String)
方法获取 non-lazy-init
的 Bean。
public Object getBean(String name) throws BeansException { |
作为公共处理逻辑,由 AbstractBeanFactory 自己实现。
+protected <T> T doGetBean( |
public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) { |
createBean 是创建 Bean 的入口方法,由 AbstractBeanFactory 定义,由 AbstractAutowireCapableBeanFactory 实现。
+protected Object createBean(String beanName, RootBeanDefinition mbd, Object[] args) throws BeanCreationException { |
常规的创建 Bean 的具体工作是由 doCreateBean 完成的。
+protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final Object[] args) throws BeanCreationException { |
创建 Bean 实例,并使用 BeanWrapper 封装。实例化的方式:
+为创建出的实例填充属性,包括解析当前 bean 所依赖的 bean。
+protected void populateBean(String beanName, RootBeanDefinition mbd, BeanWrapper bw) { |
在填充完属性后,实例就可以进行初始化工作:
+protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) { |
让 Bean 在初始化中,感知(获知)和自身相关的资源,如 beanName、beanClassLoader 或者 beanFactory。
+private void invokeAwareMethods(final String beanName, final Object bean) { |
protected void invokeInitMethods(String beanName, final Object bean, RootBeanDefinition mbd) throws Throwable { |
在调用初始化方法前后,BeanPostProcessor 先后进行两次处理。其实和 BeanPostProcessor 相关的代码都非常相似:
+public Object applyBeanPostProcessorsBeforeInitialization(Object existingBean, String beanName) throws BeansException { |
以下代码片段一度让我困惑,从注释看,初始化 Bean 实例的工作包括了 populateBean 和 initializeBean,但是 initializeBean 方法的含义就是初始化 Bean。在 initializeBean 方法中,调用了 invokeInitMethods 方法,其含义仍然是调用初始化方法。
在更熟悉代码后,我有一种微妙的、个人性的体会,在 Spring 源码中,有时候视角的变化是很快的,痕迹是很小的。如果不加以理解和区分,很容易迷失在相似的描述中。以此处为例,“初始化 Bean 和 Bean 的初始化”扩展开来是 “Bean 工厂初始化一个 Bean 和 Bean 自身进行初始化”。
// Initialize the bean instance. |
在注释这一行,视角是属于 BeanFactory(AbstractAutowireCapableBeanFactory)。从工厂的视角,面对一个刚刚创建出来的 Bean 实例,需要完成两方面的工作:
+在万事俱备之后,就是 Bean 自身的初始化工作。由于 Spring 的高度扩展性,这部分并不只是单纯地调用初始化方法,还包含 Aware 接口和 BeanPostProcessor 的相关处理,前者偏属于 Java 对象层面,后者偏属于 Spring Bean 层面。
在认同 BeanPostProcessor 的处理属于 Bean 自身初始化工作的一部分后,@PostConstruct 注解的方法被称为 Bean 的初始化方法也就不那么违和了,因为它的实现原理正是 BeanPostProcessor,这仍然在 initializeBean 的范围内。
JVM 内存区域划分为:
-Java 虚拟机栈(Java Virtual Machine Stack),线程私有,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
-可以使用 -Xss1024k
设置虚拟机栈的大小。默认情况下都是 1024k,只有 Windows 中取决于虚拟内存。
public class StackTest_4 { |
public void refresh() throws BeansException, IllegalStateException { |
并非只有自己写的递归方法可能引发栈内存溢出,有可能第三方库也会引发栈内存溢出。
-public class StackTest_5 { |
准备此 context 以供刷新,设置其启动日期和活动标志以及执行属性源的初始化。
+++编写管理资源的容器时,可以参考。
+
protected void prepareRefresh() { |
public class StackTest_3 { |
告诉子类刷新内部 bean 工厂并返回,返回的实例类型为 DefaultListableBeanFactory。在这里完成了配置文件的读取,初步注册了 bean 定义。
+++我大概这辈子都不会想理清楚这里面关于 XML 文件的解析过程,但是我知道可以在这里观察到 beanFactory 因为配置文件注册了哪些 bean。
+
protected ConfigurableListableBeanFactory obtainFreshBeanFactory() { |
public class ThreadTest_1 { |
当发现 CPU 占用率居高不下时,可以尝试以下步骤:
+配置 BeanFactory 以供在此 context 中使用,例如 context 的类加载器和一些后处理器,手动注册一些单例。
top
,定位 cpu 占用高的进程 id。ps H -eo pid,tid,%cpu | grep pid
,进一步定位引起 cpu 占用高的线程 id。jstack pid
,根据线程 id 换算成 16进制的 nid 找到对应线程,进一步定位到问题的源码行号。"thread1" #8 prio=5 os_prio=0 tid=0x00007f9bd0162800 nid=0x1061ad runnable [0x00007f9bd56eb000] |
++ignoreDependencyInterface 和 registerResolvableDependency 在理解之后比单纯地记忆它们有趣许多。
+
protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { |
public class ThreadTest_2 { |
++在创建 Bean 开始前注册的单例,都属于手动注册的单例 manualSingletonNames
+
public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { |
jstack pid
,会显示找到死锁,以及死锁涉及的线程,,并各自持有的锁还有等待的锁。堆(Heap)的特点:
-既然堆有垃圾回收机制,为什么还会发生内存溢出呢?最开始的时候,我也有这样的困惑。
后来我才认识到,还在使用中的对象是不能被强制回收的,不再使用的对象不是立刻回收的。当创建对象却没有足够的内存空间时,如果清理掉那些不再使用的对象就有足够的内存空间,就不会发生内存溢出,程序只是表现为卡顿。
public class HeapTest_1 { |
java.lang.OutOfMemoryError: Java heap space |
堆内存溢出的发生往往需要长时间的运行,因此在排查相关问题时,可以适当调小堆内存。
-jmap -heap pid
查看堆内存信息public class HeapTest_2 { |
使用 jmap -heap pid
查看堆内存信息:
Eden Space: |
使用 jconsole 查看堆内存信息:
- - -当你发现堆内存占用居高不下,经过 GC,下降也不明显,如果你想查看一下堆内的具体情况,可以将其 dump 查看。
-public class HeapTest_3 { |
可使用 VisualVM 的 Heap Dump 功能:
- +在标准初始化后修改内部 beanFactory,默认什么都不做。
+实例化并调用所有在 context 中注册的 beanFactory 后处理器,需遵循顺序规则。具体的处理被委托给 PostProcessorRegistrationDelegate。
+protected void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
也可使用 jmap -dump:format=b,file=filename.hprof pid
,需要其他分析工具搭配。
根据《Java虚拟机规范》,方法区在逻辑上是堆的一部分,但是在具体实现上,各个虚拟机厂商并不相同。对于 Hotspot 而言:
-public class MethodAreaTest_1 extends ClassLoader { |
invokeBeanFactoryPostProcessors 方法堪比裹脚布。
+关于调用顺序的规则:
不要认为自己不会写动态生成字节码相关的代码就忽略这方面的问题,如今很多框架使用字节码技术大量地动态生成类。
-二进制字节码文件主要包含三类信息:
+可能新增 beanDefinition 的情况:
public class MethodAreaTest_2 { |
public static void invokeBeanFactoryPostProcessors( |
Classfile /C:/Users/username/Documents/github/jvm-study/target/classes/com/moralok/jvm/memory/methodarea/MethodAreaTest_2.class |
注册拦截 bean
创建的 bean
后处理器。具体的处理被委托给 PostProcessorRegistrationDelegate。
protected void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory) { |
虚拟机解释器(interpreter)需要解释的字节码指令如下:
-0: getstatic #2 |
索引 #2
的意思就是去常量表里查找对应项代表的事物。
registerBeanPostProcessors 相比之下是一条清新的裹脚布。这里特别区分 3 种类型的 Bean 后处理器:
+public class DirectMemoryTest_1 { |
io 用时 1676.9797 |
public class DirectMemoryTest_2 { |
java.lang.OutOfMemoryError: Direct buffer memory |
这似乎是代码中抛出的异常,而不是真正的直接内存溢出?
-public class DirectMemoryTest_3 { |
在代码中实现手动进行直接内存的分配和释放。
-public class DirectMemoryTest_4 { |
本质上,直接内存的自动释放是利用了虚引用的机制,间接调用了 unsafe 的分配和释放直接内存的方法。
-DirectByteBuffer 就是使用 unsafe.allocateMemory(size) 分配直接内存。DirectByteBuffer 对象以及一个 Deallocator 对象(Runnable 类型)一起用于创建了一个虚引用类型的 Cleaner 对象。
-DirectByteBuffer(int cap) { |
根据虚引用的机制,如果 DirectByteBuffer 对象被回收,虚引用对象会被加入到 Cleanner 的引用队列,ReferenceHandler 线程会处理引用队列中的 Cleaner 对象,进而调用 Deallocator 对象的 run 方法。
-public void run() { |
获取指定 Bean 的入口方法是 getBean,在 Spring 上下文刷新过程中,就依次调用 AbstractBeanFactory#getBean(java.lang.String)
方法获取 non-lazy-init
的 Bean。
public Object getBean(String name) throws BeansException { |
作为公共处理逻辑,由 AbstractBeanFactory 自己实现。
-protected <T> T doGetBean( |
public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) { |
createBean 是创建 Bean 的入口方法,由 AbstractBeanFactory 定义,由 AbstractAutowireCapableBeanFactory 实现。
-protected Object createBean(String beanName, RootBeanDefinition mbd, Object[] args) throws BeanCreationException { |
常规的创建 Bean 的具体工作是由 doCreateBean 完成的。
-protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final Object[] args) throws BeanCreationException { |
ApplicationListenerDetector 既是 MergedBeanDefinitionPostProcessor,又是 DestructionAwareBeanPostProcessor,在初始化后将 listener 加入,在销毁前将 listener 移除。
+public static void registerBeanPostProcessors( |
创建 Bean 实例,并使用 BeanWrapper 封装。实例化的方式:
+添加 BeanPostProcessor 时
为创建出的实例填充属性,包括解析当前 bean 所依赖的 bean。
-protected void populateBean(String beanName, RootBeanDefinition mbd, BeanWrapper bw) { |
public void addBeanPostProcessor(BeanPostProcessor beanPostProcessor) { |
在填充完属性后,实例就可以进行初始化工作:
-protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) { |
初始化消息源。 如果在此 context 中未定义,则使用父级的。
+protected void initMessageSource() { |
让 Bean 在初始化中,感知(获知)和自身相关的资源,如 beanName、beanClassLoader 或者 beanFactory。
-private void invokeAwareMethods(final String beanName, final Object bean) { |
初始化 ApplicationEventMulticaster
。 如果上下文中未定义,则使用 SimpleApplicationEventMulticaster
。可以看得出代码的结构和 initMessageSource 是类似的。
protected void initApplicationEventMulticaster() { |
protected void invokeInitMethods(String beanName, final Object bean, RootBeanDefinition mbd) throws Throwable { |
可以重写模板方法来添加特定 context 的刷新工作。默认情况下什么都不做。
+获取侦听器 bean
并注册。无需初始化即可添加
protected void registerListeners() { |
在调用初始化方法前后,BeanPostProcessor 先后进行两次处理。其实和 BeanPostProcessor 相关的代码都非常相似:
-public Object applyBeanPostProcessorsBeforeInitialization(Object existingBean, String beanName) throws BeansException { |
添加 ApplicationListener。
+++后处理器 ApplicationListenerDetector 在 processor chain 的最后,最终会将创建的代理添加为监听器。什么情况下会出现代码中预防的情况呢?
+
public void addApplicationListener(ApplicationListener<?> listener) { |
以下代码片段一度让我困惑,从注释看,初始化 Bean 实例的工作包括了 populateBean 和 initializeBean,但是 initializeBean 方法的含义就是初始化 Bean。在 initializeBean 方法中,调用了 invokeInitMethods 方法,其含义仍然是调用初始化方法。
在更熟悉代码后,我有一种微妙的、个人性的体会,在 Spring 源码中,有时候视角的变化是很快的,痕迹是很小的。如果不加以理解和区分,很容易迷失在相似的描述中。以此处为例,“初始化 Bean 和 Bean 的初始化”扩展开来是 “Bean 工厂初始化一个 Bean 和 Bean 自身进行初始化”。
// Initialize the bean instance. |
实例化所有剩余的(非惰性初始化)单例。以 context 视角,是完成内部 beanFactory 的初始化。
+几乎可以只关注最后的 beanFactory.preInstantiateSingletons()
。
protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) { |
在注释这一行,视角是属于 BeanFactory(AbstractAutowireCapableBeanFactory)。从工厂的视角,面对一个刚刚创建出来的 Bean 实例,需要完成两方面的工作:
-在万事俱备之后,就是 Bean 自身的初始化工作。由于 Spring 的高度扩展性,这部分并不只是单纯地调用初始化方法,还包含 Aware 接口和 BeanPostProcessor 的相关处理,前者偏属于 Java 对象层面,后者偏属于 Spring Bean 层面。
在认同 BeanPostProcessor 的处理属于 Bean 自身初始化工作的一部分后,@PostConstruct 注解的方法被称为 Bean 的初始化方法也就不那么违和了,因为它的实现原理正是 BeanPostProcessor,这仍然在 initializeBean 的范围内。
确保所有非惰性初始化单例都已实例化,同时还要考虑 FactoryBeans
。 如果需要,通常在工厂设置结束时调用。
++先对集合进行 Copy 再迭代是很常见的处理方式,可以有效保证迭代时不受原集合影响,也不会影响到原集合。
+
|
最后一步,完成 context 刷新,比如发布相应的事件。
+protected void finishRefresh() { |
public class ByteCodeTest_2 { |
在编译期间就可计算得到操作数栈和本地变量表的大小。
-stack=2, locals=4, args_size=1 |
Slot,即槽位,可理解为索引。
-Start Length Slot Name Signature |
3 = Integer 32768 |
0: bipush 10 |
每一次重新阅读,都有新的收获,也将过去这段时间以来一些新的零散的知识点串联在一起。
沿着周志明老师的行文脉络,了解问题发生的背景,当时的人如何思考,提出了哪些方案,各自有什么优缺点,附带产生的问题如何解决,理论研究如何应用到工程实践中,就像真实地经历一段研发历史。这让人对垃圾收集的认识不再停留在记忆上,而是深入到理解中,相关的知识点不再是空中楼阁,无根之水,而是从一些事实基础和问题自然延申出来。
尽管在更底层的实现上仍然缺乏认识和想象力,以至于在一些细节上还是疑惑重重,但是仍然有豁然开朗的感觉呢。比如以前看不同垃圾收集器的过程示意图如鸡肋,如今看其中的停顿和并发,只觉充满智慧。
垃圾收集(Garbage Collection,简称 GC)需要考虑什么?
为什么要去了解垃圾收集和内存分配?
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化技术”实施必要的监控和调节。
在 Java 中,垃圾收集需要关注哪些内存区域?
程序计数器、虚拟机栈和本地方法栈,随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着入栈和出栈操作,每个栈帧中分配多少内存可以认为是编译期可知的,因此这几个区域地内存分配和回收具备确定性。
但是 Java 堆和方法区这两个区域则有显著的不确定性,只有运行期间,我们才知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
哪些对象是还存活着,哪些已经死亡?
+++对象死亡即不可能再被任何途径使用。其实曾经的我会怀疑,遗落在内存中的对象,真的没有办法“魔法般地”获取其引用地址吗?引用变量的值不就是 64 位的数字吗?
+
优点:
+public class ByteCodeTest_3 { |
0: bipush 10 |
public class ByteCodeTest_4 { |
0: iconst_0 |
<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。每一次重新阅读,都有新的收获,也将过去这段时间以来一些新的零散的知识点串联在一起。
沿着周志明老师的行文脉络,了解问题发生的背景,当时的人如何思考,提出了哪些方案,各自有什么优缺点,附带产生的问题如何解决,理论研究如何应用到工程实践中,就像真实地经历一段研发历史。这让人对垃圾收集的认识不再停留在记忆上,而是深入到理解中,相关的知识点不再是空中楼阁,无根之水,而是从一些事实基础和问题自然延申出来。
尽管在更底层的实现上仍然缺乏认识和想象力,以至于在一些细节上还是疑惑重重,但是仍然有豁然开朗的感觉呢。比如以前看不同垃圾收集器的过程示意图如鸡肋,如今看其中的停顿和并发,只觉充满智慧。
垃圾收集(Garbage Collection,简称 GC)需要考虑什么?
-为什么要去了解垃圾收集和内存分配?
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化技术”实施必要的监控和调节。
在 Java 中,垃圾收集需要关注哪些内存区域?
程序计数器、虚拟机栈和本地方法栈,随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着入栈和出栈操作,每个栈帧中分配多少内存可以认为是编译期可知的,因此这几个区域地内存分配和回收具备确定性。
但是 Java 堆和方法区这两个区域则有显著的不确定性,只有运行期间,我们才知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
哪些对象是还存活着,哪些已经死亡?
---对象死亡即不可能再被任何途径使用。其实曾经的我会怀疑,遗落在内存中的对象,真的没有办法“魔法般地”获取其引用地址吗?引用变量的值不就是 64 位的数字吗?
-
优点:
-缺点:
+缺点:
很多人都了解在必要的时候需要使用分布式锁来限制程序的并发执行,但是在具体的细节上,往往并不正确。
-本质上要实现的目标就是在 Redis 中占坑,告诉后来者资源已经被锁定,放弃或者稍后重试。Redis 原生支持 set if not exists 的语义。
-setnx lock:user1 true |
如果在处理过程中,程序出现异常,将导致 del 指令没有执行成功。锁无法释放,其他线程将无法再获取锁。
- - -对 key 设置过期时间,如果在处理过程中,程序出现异常,导致 del 指令没有执行成功,设置的过期时间一到,key 将自动被删除,锁也就等于被释放了。
-setnx lock:user1 true |
事实上,上述措施并没有彻底解决问题。如果在设置 key 的超时时间之前,程序出现异常,一切仍旧会发生。
- - -本质原因是 setnx 和 expire 两个指令不是一个原子操作。那么是否可以使用 Redis 的事务解决呢?不行。因为 expire 依赖于 setnx 的执行结果,如果 setnx 没有成功,expire 就不应该执行。
-如果 setnx 和 expire 可以用一个原子指令实现就好了。
- - -在 Redis 2.8 版本中,Redis 的作者加入 set 指令扩展参数,允许 setnx 和 expire 组合成一个原子指令。
-set lock:user1 true ex 5 nx |
public class ByteCodeTest_2 { |
除了使用原生的指令外,还可以使用 Lua 脚本,将多个 Redis 指令组合成一个原子指令。
-if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then |
在编译期间就可计算得到操作数栈和本地变量表的大小。
+stack=2, locals=4, args_size=1 |
基于 Redis 的分布式锁还会面临超时问题。如果在加锁和释放之间的处理逻辑过于耗时,以至于超出了 key 的过期时间,锁将在处理结束前被释放,就可能发生问题。
- +Slot,即槽位,可理解为索引。
+Start Length Slot Name Signature |
如果第一个线程因为处理逻辑过于耗时导致在处理结束前锁已经被释放,其他线程将可以提前获得锁,临界区的代码将不能保证严格串行执行。
-如果在第二个线程获得锁后,第一个线程刚好处理逻辑结束去释放锁,将导致第二个线程的锁提前被释放,引发连锁问题。
-与其说是改进,不如说是注意事项。如果真的出现问题,造成的数据错误可能需要人工介入解决。
-如果真的存在这样的业务场景,应考虑使用其他解决方案加以优化。
-为 Redis 的 key 设置过期时间,其实是为了解决死锁问题而做出的兜底措施。可以为获得的锁设置定时任务定期地为锁续期,以避免锁被提前释放。
-private void scheduleRenewal() { |
3 = Integer 32768 |
但是这个方式仍然不能避免解锁失败时的其他线程的等待时间。
-可以将 set 指令的 value 参数设置为一个随机数,释放锁时先匹配持有的 tag 是否和 value 一致,如果一致再删除 key,以此避免锁被其他线程错误释放。
-tag = random.nextint() |
但是注意,Redis 并没有提供语义为 delete if equals 的原子指令,这样的话问题并不能被彻底解决。如果在第一个线程判断 tag 是否和 value 相等之后,第二个线程刚好获得了锁,然后第一个线程因为匹配成功执行删除 key 操作,仍然将导致第二个线程获得的锁被第一个线程错误释放。
- +0: bipush 10 |
public class ByteCodeTest_3 { |
if redis.call("get", KEYS[1]) == ARGV[1] then |
0: bipush 10 |
可重入性是指线程在已经持有锁的情况下再次请求加锁,如果一个锁支持同一个线程多次加锁,那么就称这个锁是可重入的,类似 Java 的 ReentrantLock。
-Redis 分布式锁如果要支持可重入,可以使用线程的 ThreadLocal 变量存储当前持有的锁计数。但是在多次获得锁后,过期时间并没有得到延长,后续获得锁后持有锁的时间其实比设置的时间更短。
-private ThreadLocal<Integer> lockCount = ThreadLocal.withInitial(() -> 0); |
public class ByteCodeTest_4 { |
还可以使用 Redis 的 hash 数据结构实现锁计数,支持重新获取锁后重置过期时间。
-if (redis.call('exists', KEYS[1]) == 0) then |
0: iconst_0 |
书的作者不推荐使用可重入锁,他提出可重入锁会加重客户端的复杂度,如果在编写代码时注意在逻辑结构上进行调整,完全可以避免使用可重入锁。
-<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。<cond>
,一个 int 和 0 的比较成立时进入分支,跳转到指定行号。Dockers Compose
安装 Grafana
和 Prometheus
在局域网中配合各类 exporter
为主机和诸多内部服务搭建监控。
-
+ JVM 内存区域划分为:
+version: "1.0" |
Java 虚拟机栈(Java Virtual Machine Stack),线程私有,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
+可以使用 -Xss1024k
设置虚拟机栈的大小。默认情况下都是 1024k,只有 Windows 中取决于虚拟内存。
public class StackTest_4 { |
在通过 nginx
代理 Grafana
后,出现 "Origin not allowed"
报错信息。
问题的原因参考官方社区:After update to 8.3.5: ‘Origin not allowed’ behind proxy
-在 nginx
配置文件的 proxy
配置上方添加 proxy_set_header Host $http_host
,然后重启 nginx
恢复正常。
location / { |
并非只有自己写的递归方法可能引发栈内存溢出,有可能第三方库也会引发栈内存溢出。
+public class StackTest_5 { |
version: "1.0" |
public class StackTest_3 { |
global: |
public class ThreadTest_1 { |
当发现 CPU 占用率居高不下时,可以尝试以下步骤:
+top
,定位 cpu 占用高的进程 id。ps H -eo pid,tid,%cpu | grep pid
,进一步定位引起 cpu 占用高的线程 id。jstack pid
,根据线程 id 换算成 16进制的 nid 找到对应线程,进一步定位到问题的源码行号。"thread1" #8 prio=5 os_prio=0 tid=0x00007f9bd0162800 nid=0x1061ad runnable [0x00007f9bd56eb000] |
node_exporter
被设计为监控主机系统,因为它需要访问主机系统,所以不推荐使用 Docker
容器部署。
node_exporter
二进制可执行文件。chmod u+x node_exporter
./node_exporter
启动Prometheus
配置文件global: |
public class ThreadTest_2 { |
jstack pid
,会显示找到死锁,以及死锁涉及的线程,,并各自持有的锁还有等待的锁。/etc/systemd/system
node_exporter.service
[Unit] |
堆(Heap)的特点:
+既然堆有垃圾回收机制,为什么还会发生内存溢出呢?最开始的时候,我也有这样的困惑。
后来我才认识到,还在使用中的对象是不能被强制回收的,不再使用的对象不是立刻回收的。当创建对象却没有足够的内存空间时,如果清理掉那些不再使用的对象就有足够的内存空间,就不会发生内存溢出,程序只是表现为卡顿。
public class HeapTest_1 { |
java.lang.OutOfMemoryError: Java heap space |
堆内存溢出的发生往往需要长时间的运行,因此在排查相关问题时,可以适当调小堆内存。
+jmap -heap pid
查看堆内存信息public class HeapTest_2 { |
使用 jmap -heap pid
查看堆内存信息:
Eden Space: |
使用 jconsole 查看堆内存信息:
+ + +当你发现堆内存占用居高不下,经过 GC,下降也不明显,如果你想查看一下堆内的具体情况,可以将其 dump 查看。
+public class HeapTest_3 { |
可使用 VisualVM 的 Heap Dump 功能:
+ + +也可使用 jmap -dump:format=b,file=filename.hprof pid
,需要其他分析工具搭配。
根据《Java虚拟机规范》,方法区在逻辑上是堆的一部分,但是在具体实现上,各个虚拟机厂商并不相同。对于 Hotspot 而言:
+public class MethodAreaTest_1 extends ClassLoader { |
不要认为自己不会写动态生成字节码相关的代码就忽略这方面的问题,如今很多框架使用字节码技术大量地动态生成类。
+二进制字节码文件主要包含三类信息:
+public class MethodAreaTest_2 { |
Classfile /C:/Users/username/Documents/github/jvm-study/target/classes/com/moralok/jvm/memory/methodarea/MethodAreaTest_2.class |
虚拟机解释器(interpreter)需要解释的字节码指令如下:
+0: getstatic #2 |
索引 #2
的意思就是去常量表里查找对应项代表的事物。
public class DirectMemoryTest_1 { |
io 用时 1676.9797 |
public class DirectMemoryTest_2 { |
java.lang.OutOfMemoryError: Direct buffer memory |
这似乎是代码中抛出的异常,而不是真正的直接内存溢出?
+public class DirectMemoryTest_3 { |
在代码中实现手动进行直接内存的分配和释放。
+public class DirectMemoryTest_4 { |
本质上,直接内存的自动释放是利用了虚引用的机制,间接调用了 unsafe 的分配和释放直接内存的方法。
+DirectByteBuffer 就是使用 unsafe.allocateMemory(size) 分配直接内存。DirectByteBuffer 对象以及一个 Deallocator 对象(Runnable 类型)一起用于创建了一个虚引用类型的 Cleaner 对象。
+DirectByteBuffer(int cap) { |
根据虚引用的机制,如果 DirectByteBuffer 对象被回收,虚引用对象会被加入到 Cleanner 的引用队列,ReferenceHandler 线程会处理引用队列中的 Cleaner 对象,进而调用 Deallocator 对象的 run 方法。
+public void run() { |
很多人都了解在必要的时候需要使用分布式锁来限制程序的并发执行,但是在具体的细节上,往往并不正确。
+本质上要实现的目标就是在 Redis 中占坑,告诉后来者资源已经被锁定,放弃或者稍后重试。Redis 原生支持 set if not exists 的语义。
+setnx lock:user1 true |
如果在处理过程中,程序出现异常,将导致 del 指令没有执行成功。锁无法释放,其他线程将无法再获取锁。
+ + +对 key 设置过期时间,如果在处理过程中,程序出现异常,导致 del 指令没有执行成功,设置的过期时间一到,key 将自动被删除,锁也就等于被释放了。
+setnx lock:user1 true |
事实上,上述措施并没有彻底解决问题。如果在设置 key 的超时时间之前,程序出现异常,一切仍旧会发生。
+ + +本质原因是 setnx 和 expire 两个指令不是一个原子操作。那么是否可以使用 Redis 的事务解决呢?不行。因为 expire 依赖于 setnx 的执行结果,如果 setnx 没有成功,expire 就不应该执行。
+如果 setnx 和 expire 可以用一个原子指令实现就好了。
+ + +在 Redis 2.8 版本中,Redis 的作者加入 set 指令扩展参数,允许 setnx 和 expire 组合成一个原子指令。
+set lock:user1 true ex 5 nx |
除了使用原生的指令外,还可以使用 Lua 脚本,将多个 Redis 指令组合成一个原子指令。
+if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then |
基于 Redis 的分布式锁还会面临超时问题。如果在加锁和释放之间的处理逻辑过于耗时,以至于超出了 key 的过期时间,锁将在处理结束前被释放,就可能发生问题。
+ + +如果第一个线程因为处理逻辑过于耗时导致在处理结束前锁已经被释放,其他线程将可以提前获得锁,临界区的代码将不能保证严格串行执行。
+如果在第二个线程获得锁后,第一个线程刚好处理逻辑结束去释放锁,将导致第二个线程的锁提前被释放,引发连锁问题。
+与其说是改进,不如说是注意事项。如果真的出现问题,造成的数据错误可能需要人工介入解决。
+如果真的存在这样的业务场景,应考虑使用其他解决方案加以优化。
+为 Redis 的 key 设置过期时间,其实是为了解决死锁问题而做出的兜底措施。可以为获得的锁设置定时任务定期地为锁续期,以避免锁被提前释放。
+private void scheduleRenewal() { |
但是这个方式仍然不能避免解锁失败时的其他线程的等待时间。
+可以将 set 指令的 value 参数设置为一个随机数,释放锁时先匹配持有的 tag 是否和 value 一致,如果一致再删除 key,以此避免锁被其他线程错误释放。
+tag = random.nextint() |
但是注意,Redis 并没有提供语义为 delete if equals 的原子指令,这样的话问题并不能被彻底解决。如果在第一个线程判断 tag 是否和 value 相等之后,第二个线程刚好获得了锁,然后第一个线程因为匹配成功执行删除 key 操作,仍然将导致第二个线程获得的锁被第一个线程错误释放。
+ + +if redis.call("get", KEYS[1]) == ARGV[1] then |
可重入性是指线程在已经持有锁的情况下再次请求加锁,如果一个锁支持同一个线程多次加锁,那么就称这个锁是可重入的,类似 Java 的 ReentrantLock。
+Redis 分布式锁如果要支持可重入,可以使用线程的 ThreadLocal 变量存储当前持有的锁计数。但是在多次获得锁后,过期时间并没有得到延长,后续获得锁后持有锁的时间其实比设置的时间更短。
+private ThreadLocal<Integer> lockCount = ThreadLocal.withInitial(() -> 0); |
还可以使用 Redis 的 hash 数据结构实现锁计数,支持重新获取锁后重置过期时间。
+if (redis.call('exists', KEYS[1]) == 0) then |
书的作者不推荐使用可重入锁,他提出可重入锁会加重客户端的复杂度,如果在编写代码时注意在逻辑结构上进行调整,完全可以避免使用可重入锁。
+Dockers Compose
安装 Grafana
和 Prometheus
在局域网中配合各类 exporter
为主机和诸多内部服务搭建监控。
+
+
+version: "1.0" |
在通过 nginx
代理 Grafana
后,出现 "Origin not allowed"
报错信息。
问题的原因参考官方社区:After update to 8.3.5: ‘Origin not allowed’ behind proxy
+在 nginx
配置文件的 proxy
配置上方添加 proxy_set_header Host $http_host
,然后重启 nginx
恢复正常。
location / { |
version: "1.0" |
global: |
node_exporter
被设计为监控主机系统,因为它需要访问主机系统,所以不推荐使用 Docker
容器部署。
node_exporter
二进制可执行文件。chmod u+x node_exporter
./node_exporter
启动Prometheus
配置文件global: |
/etc/systemd/system
node_exporter.service
[Unit] |
Dubbo SPI
自适应拓展是什么样子,是一种非常好的表现其作用的方式。正如官方博客中所说的,它让人对自适应拓展有更加感性的认识,避免读者一开始就陷入复杂的代码生成逻辑。本文在此基础上,从更原始的使用方式上展现“动态加载”技术对“按需加载”的天然倾向,从更普遍的角度解释自适应拓展的本质目的,在介绍 Dubbo
的具体实现是如何约束自身从而规避缺点之后,详细梳理了 Dubbo SPI
自适应拓展的相关源码和工作原理。
+ Configuration
注解是 Spring
中常用的注解,在一般的应用场景中,它用于标识一个类作为配置类,搭配 Bean
注解将创建的 bean
交给 Spring
容器管理。神奇的是,被 Bean
注解标注的方法,只会被真正调用一次。这种方法调用被拦截的情况很容易让人联想到代理,如果你在 Debug
时注意过配置类的实例,你会发现配置类的 Class
名称中携带 EnhancerBySpringCGLIB
。本文将从源码角度,分析 Configuration
注解是如何工作的。
+
|
|
通常情况下,我们称被 Configuration
注解标注的类为配置类。事实上,配置类的范围比这个定义稍微广泛一些,可以划分为全配置类和精简配置类。在解析配置类时,我们再进一步说明。
ApplicationContext ac = new AnnotationConfigApplicationContext(BeanConfig.class); |
本文不详细介绍配置类本身如何注册到 BeanFactory
中。当 BeanConfig
被传递给 AnnotationConfigApplicationContext
,自身会先被解析为 BeanDefinition
注册到 beanFactory
中。有两点需要注意:
annotatedClasses
可以传入多个,意味着一开始静态指定的配置类可以有多个。annotatedClasses
除了在命名上提示用户应传入被注解的类外,register(annotatedClasses)
实际上只是将它们视作普通的 Bean
注册到 beanFactory
中。它们是从外界传入的**首批 BeanDefinition
**。之后 Spring
进入 refresh
流程。使用 IDEA Debug
观察此时的 beanDefinitionMap
,除了 beanConfig
外,AnnotationConfigApplicationContext
在创建时,已经自动注册了 6
个 bean
定义,其中一个就是我们今天的主角 org.springframework.context.annotation.internalConfigurationAnnotationProcessor -> org.springframework.context.annotation.ConfigurationClassPostProcessor
。显而易见,此时配置类还未被处理得到新的 bean
定义。
配置类后处理器 ConfigurationClassPostProcessor
实现了接口 BeanDefinitionRegistryPostProcessor
,也因此同时实现了接口 BeanFactoryPostProcessor
。在Spring 应用 context 刷新流程中,我们介绍过这两个接口,它们作为工厂后处理器,被用于 refresh
过程的调用工厂后处理器阶段(invokeBeanFactoryPostProcessors(beanFactory)
)。工厂后处理器的作用,一言以蔽之,允许自定义修改应用上下文中的 bean 定义。
配置类后处理器 ConfigurationClassPostProcessor
的具体作用可以概括为两点:
Bean
,将它们的 bean
定义注册到 BeanFactory
中。根据之前的介绍,进入 invokeBeanFactoryPostProcessors(beanFactory)
,ConfigurationClassPostProcessor
会先作为 BeanDefinitionRegistryPostProcessor
被调用。
--站在现有设计回头看的视角更偏向于展现为什么这样设计很好,却并不好展现如果不这样设计会有什么问题,以至于有时候会有种这个设计很妙,但妙在哪里体会不够深的感觉。思考一项技术如何从最初发展到现在,解决以及试图解决哪些问题,因此可能引入哪些问题,也许脑补的并不完全符合历史事实,但仍然会让人更加深刻地认识这项技术本身,体会设计中的巧思,并避免一直陷在庞杂的细节处理中。
+个人的理解是,先将
BeanFactory
视作BeanDefinitionRegistry
注册好BeanDefinition
,再视作BeanFactory
进行处理,有点预备好原材料再统一处理的意思。
在 Dubbo
中,很多拓展都是通过 SPI
机制动态加载的,比如 Protocol
、Cluster
和 LoadBalance
等。有些拓展我们并不想在框架启动阶段被加载,而是希望在拓展方法被调用时,根据运行时参数进行加载。为了让大家对自适应拓展有一个感性的认识,下面我们通过一个实例进行演示。
定义一个接口 Animal
。
public interface Animal { |
定义两个实现类 Dog
和 Cat
。
public class Dog implements Animal { |
|
在运行时根据参数动态地加载拓展。
-public void bark(String type) { |
++核心方法
+processConfigBeanDefinitions(registry)
冗长,个人建议无需过度关注细节(但同时个人感受是反复阅读和Debug
确实有益于加深理解,看个人时间和精力)。
基于配置类的 BeanDefinition Registry
(也就是 BeanFactory
),获取配置类,构建和校验配置模型:
BeanDefinition Registry
(即 BeanFactory
)中查找配置类。BeanDefinitions
注册到 BeanDefinition Registry
。BeanDefinitions
可能有新的配置类,回到 1
再来一遍。重复循环直到不再引入新的配置类。以本文示例进行说明,静态添加的配置类只有 BeanConfig
,假如 BeanConfig
不仅被 Configuration
注解标注,还被 ComponentScan
注解标注,并且刚好 Spring
通过扫描获得并添加了新的配置类,那么新的配置类就需要继续被解析。
++ -应正视配置模型这个概念,它可以理解为配置类到
+BeanDefinitions
的中间产物。最初我先入为主,带着解析得到 BeanDefinitions
这样“一阶段”完成的观念,非常不理解processConfigBeanDefinitions
方法上Build and validate a configuration model based on the registry of Configuration classes
这句注释。先行强调注意,处理配置类得到bean
定义分为“两阶段”,解析配置类得到配置模型,从配置模型中读取bean
定义。
是不是感觉平平无奇?没错,当你拥有动态加载的能力后,按需加载是自然而然会产生的想法,并不是什么高大上的设计。两者甚至不仅仅是天性相合,可能更像是你中有我,我中有你。在正常场景中,这样一段代码也并不需要进一步被抽象和重构,它本身就很简洁。现在设想一下,你的应用中,有大量的拓展需要动态加载,你可能需要在很多地方写很多根据运行时参数动态加载拓展并调用方法的代码,就像下面这样:
-Animal animal = ExtensionLoader.getExtensionLoader(Animal.class).getExtension(type); |
这会带来一些小问题,总是需要写 ExtensionLoader.getExtensionLoader(XXX.class).getExtension(parameter)
这样重复的代码;引入了 ExtensionLoader
这个“中介”,不能直面拓展本身。后者可能有点难以体会,以动物园 Zoo
和 动物 Animal
举例。
在非动态加载情况下,我们可能会这样写:
-public class Zoo { |
在动态加载情况下,我们可能会这样写。在这种情况下,Zoo
没有合适的方式直接持有 Animal
,而是通过 ExtensionLoader
间接地持有。
public class Zoo { |
我们更想要以下这种直接持有 Animal
的方式,在运行时 animal
可以是 Dog
,也可以是 Cat
,还可以是其他的动物。
public class Zoo { |
Dubbo
采用了一种称为“自适应拓展”的巧妙设计,通过代理的方式,将动态加载拓展的代码整合到代理类(具体实现类)中。使用方调用代理对象,代理对象根据参数动态加载拓展并调用。例如 Animal
的自适应拓展,就像下面这样:
public class AdaptiveAnimal implements Animal { |
当然,我们不希望需要手动地为每一个拓展编写 Adaptive
代理类,事实上,我们以往接触到的代理方案,大都是自动生成代理的,应该也不会有人会接受完全手写的方式。然而你可能会注意到一个不够和谐的缺点,bark
方法的参数列表中新增了 type
类型,这不太符合面向对象的设计原则。想象一个更奇怪的场景,我们要为一个方法引入与它本身格格不入的参数用于获取拓展。另外,我们可能需要通过一些标记或约定来告诉代理生成器,方法参数列表中哪一个参数是用于获取拓展的。事实上,Dubbo
的另一个设计规避了这一缺点,Dubbo
在公共契约中提到:所有扩展点参数都包含 URL
参数,URL
作为上下文信息贯穿整个扩展点设计体系。因此围绕着 Dubbo
以 URL
为中心的拓展体系,你很难设计出 Animal.bark(URL url)
这样不和谐的方法签名,也不用担心参数列表千奇百怪的情况。同时 Dubbo
并未完全抛弃手工编写自适应拓展的方式,而是予以保留。
在在 Dubbo
中,尽管很少但仍然存在手工编码的自适应拓展,这类拓展允许你不使用 URL
作为参数,查看它们的代码可以帮助我们更好地理解自适应拓展是如何在真实的应用场景中发挥作用的。以下是 ExtensionFactory
的自适应拓展,当你调用它的 getExtension
方法时,它就是将工作全权委托给 factory.getExtension(type, name)
完成的,而 factories
在创建 AdaptiveExtensionFactory
时就已经获取了。
|
至此,我们提到了按需加载是具备动态加载能力后自然的倾向,介绍了在拥有大量拓展情况下演变而来的自适应拓展设计,它的缺点和 Dubbo 是如何规避的。接下来,我们将进入源码分析部分。
-Adaptive
注解是一个与自适应拓展息息相关的注解,该定义如下:
|
根据 Target
注解的 value
可知,Adaptive
注解可标注在类或者方法上。当 Adaptive
注解标注在类上时,Dubbo
不会为该类生成代理类。当 Adaptive
注解标注在接口方法上时,Dubbo
则会为该方法生成代理逻辑。Adaptive
注解在类上的情况很少,在 Dubbo
中,仅有两个类被 Adaptive
注解标注,分别是 AdaptiveCompiler
和 AdaptiveExtensionFactory
。在这种情况下,拓展的加载逻辑由人工编码完成。在更多时候,Adaptive
注解是标注在接口方法上的,这表示拓展的加载逻辑需由框架自动生成。
获取自适应拓展的入口方法是 getAdaptiveExtension
,使用 getOrCreate
的模式获取。
public T getAdaptiveExtension() { |
当缓存为空时,就会通过 createAdaptiveExtension
方法创建。方法包含以下三个处理逻辑:
getAdaptiveExtensionClass
方法获取自适应拓展的 Class
对象。injectExtension
方法对拓展实例进行依赖注入。--手工编码的自适应拓展可能依赖其他拓展,但是框架生成的自适应拓展并不依赖其他拓展。
-
private T createAdaptiveExtension() { |
获取自适应拓展类的 getAdaptiveExtensionClass
方法包含以下三个处理逻辑:
getExtensionClasses
方法获取所有拓展类。cachedAdaptiveClass
,如果不为 null
,则返回缓存。null
,则调用 createAdaptiveExtensionClass
创建自适应拓展类(代理类)。在Dubbo SPI 的工作原理中我们分析过 getExtensionClasses
方法,在获取拓展的所有实现类时,如果某个实现类被 Adaptive
注解标注了,那么该类就会被赋值给 cachedAdaptiveClass
变量。“原理”部分介绍的 AdaptiveExtensionFactory
就属于这种情况,我们不再细谈。按前文所说,在绝大多数情况下,Adaptive
注解都是用于标注方法而非标注具体的实现类,因此在大多数情况下程序都会走第三个步骤,由框架自动生成自适应拓展类(代理类)。
private Class<?> getAdaptiveExtensionClass() { |
--到目前为止,获取自适应拓展的过程和获取普通拓展的过程是非常相似的,使用
-getOrCreate
的模式获取拓展,如果缓存为空则创建,创建的时候会先加载全部的拓展实现类,从中获取目标类,通过反射进行实例化,最后进行依赖注入。区别在于获取目标类时,在自适应拓展情况下,返回的可能是一个生成的代理类。生成的过程非常复杂,是我们接下来关注的重点。
生成自适应拓展类的方式相比于以往接触的生成代理类的方式更加“直观且容易理解”,但是相应的,拼接字符串部分的代码并不容易阅读。
-Class
对象。--在新版本中,这部分代码的可读性有了非常大的提升,原先冗长的处理逻辑被抽象为多个命名含义清晰的方法。
-
private Class<?> createAdaptiveExtensionClass() { |
为了更直观地了解代码生成的效果及其实现的功能,以 Protocol
为例,生成的完整代码(已经经过格式化)展示如下。
package org.apache.dubbo.rpc; |
生成的代理类需完成以下功能:
-adaptive
方法,直接抛出异常。adaptive
方法:URL
对象,结合 URL
对象和默认拓展名得到最终的拓展名 extName
。ExtensionLoader
,再根据拓展名 extName
获取拓展,最后调用拓展的同名方法。以上的功能在表面上看来并不复杂,事实上,想要实现的目标处理逻辑也并不复杂,只在为了提供足够的可扩展性,具体实现变得很复杂。复杂的处理逻辑主要集中在如何为“准备工作”部分生成相应的代码,大概可以总结为:在获取拓展前,Dubbo
会直接或间接地从参数列表中查找 URL
对象,所谓直接就是 URL
对象直接在参数列表中,所谓间接就是 URL
对象是其中一个参数的属性。在得到 URL
对象后,Dubbo
会尝试以 Adaptive
注解的 value
为 key
,从 URL
中获取值作为拓展名,如果获取不到则使用默认拓展名 defaultExtName
。实际的实现更加复杂,需要耐心阅读和测试。
新版本将代码生成的逻辑抽象到自适应拓展类代码生成器中,注意参数只有 type
和 defaultExtName
,从这里也可以看出如何确定最终加载的拓展,取决于这两个参数和被调用方法的入参。
public AdaptiveClassCodeGenerator(Class<?> type, String defaultExtName) { |
在生成代理类源码之前,generate
方法会先通过反射检测接口方法中是否至少有一个标注了 Adaptive
注解,若不满足,就会抛出异常。
--流式编程使用得当的话很有可读性啊。
-
private boolean hasAdaptiveMethod() { |
生成代理类源码的顺序和普通 Java
类文件中内容的顺序一致:
先忽略“生成方法”的部分,以 Dubbo
的 Protocol
拓展为例,生成的代码如下:
package org.apache.dubbo.rpc; |
生成方法的过程同样被抽象为几个命名含义清晰的方法,包含以下五个部分:
-private String generateMethod(Method method) { |
除了最重要的“方法内容”部分,其他部分都是复制原方法的信息,并不复杂。生成“方法内容”部分,分为是否被 Adaptive
注解标注。
private String generateMethodContent(Method method) { |
对于无 Adaptive
注解标注的方法,生成逻辑很简单,就是生成抛出异常的代码。
private String generateUnsupported(Method method) { |
以 Protocol
接口的 destroy
方法为例,生成的内容如下:
throw new UnsupportedOperationException("The method public abstract void org.apache.dubbo.rpc.Protocol.destroy() of interface org.apache.dubbo.rpc.Protocol is not adaptive method!"); |
对于有 Adaptive 注解标注的方法,
-// 查找 URL 类型的参数 |
直接从方法的参数类型列表中查找第一个 URL
类型的参数,返回其索引。
private int getUrlTypeIndex(Method method) { |
间接从方法的参数类型列表中,查找 URL
类型的参数,并生成判空检查和赋值代码。
private String generateUrlAssignmentIndirectly(Method method) { |
以 Protocol
的 refer
和 export
方法为例,生成的内容如下:
// refer |
private String[] getMethodAdaptiveValue(Adaptive adaptiveAnnotation) { |
检测是否有 Invocation
类型的参数,并生成判空检查代码和赋值代码。从 Invocation
可以获得 methodName
。
private boolean hasInvocationArgument(Method method) { |
以 LoadBalance
的 select
方法为例,生成的内容如下:
if (arg2 == null) throw new IllegalArgumentException("invocation == null"); |
本方法用于根据 SPI
和 Adaptive
注解的 value
生成“获取拓展名”的代码,同时生成逻辑还受 Invocation
影响,因此相对复杂。总结的规则如下:
private String generateExtNameAssignment(String[] value, boolean hasInvocation) { |
private String generateExtensionAssignment() { |
以 Protocol
接口的 refer
方法为例,生成的内容如下:
org.apache.dubbo.rpc.Protocol extension = (org.apache.dubbo.rpc.Protocol)ExtensionLoader.getExtensionLoader(org.apache.dubbo.rpc.Protocol.class).getExtension(extName); |
生成方法调用语句,如有必要,返回结果。
-private String generateReturnAndInvocation(Method method) { |
以 Protocol
接口的 refer
方法为例,生成的内容如下:
return extension.refer(arg0, arg1); |
--新版本通过提炼方法、使用流式编程和使用
-String.format()
代替 StringBuilder,提供了更好的代码可读性。官方写得源码解析真好。
Configuration
注解是 Spring
中常用的注解,在一般的应用场景中,它用于标识一个类作为配置类,搭配 Bean
注解将创建的 bean
交给 Spring
容器管理。神奇的是,被 Bean
注解标注的方法,只会被真正调用一次。这种方法调用被拦截的情况很容易让人联想到代理,如果你在 Debug
时注意过配置类的实例,你会发现配置类的 Class
名称中携带 EnhancerBySpringCGLIB
。本文将从源码角度,分析 Configuration
注解是如何工作的。
-
-
-
|
|
通常情况下,我们称被 Configuration
注解标注的类为配置类。事实上,配置类的范围比这个定义稍微广泛一些,可以划分为全配置类和精简配置类。在解析配置类时,我们再进一步说明。
ApplicationContext ac = new AnnotationConfigApplicationContext(BeanConfig.class); |
本文不详细介绍配置类本身如何注册到 BeanFactory
中。当 BeanConfig
被传递给 AnnotationConfigApplicationContext
,自身会先被解析为 BeanDefinition
注册到 beanFactory
中。有两点需要注意:
annotatedClasses
可以传入多个,意味着一开始静态指定的配置类可以有多个。annotatedClasses
除了在命名上提示用户应传入被注解的类外,register(annotatedClasses)
实际上只是将它们视作普通的 Bean
注册到 beanFactory
中。它们是从外界传入的**首批 BeanDefinition
**。之后 Spring
进入 refresh
流程。使用 IDEA Debug
观察此时的 beanDefinitionMap
,除了 beanConfig
外,AnnotationConfigApplicationContext
在创建时,已经自动注册了 6
个 bean
定义,其中一个就是我们今天的主角 org.springframework.context.annotation.internalConfigurationAnnotationProcessor -> org.springframework.context.annotation.ConfigurationClassPostProcessor
。显而易见,此时配置类还未被处理得到新的 bean
定义。
配置类后处理器 ConfigurationClassPostProcessor
实现了接口 BeanDefinitionRegistryPostProcessor
,也因此同时实现了接口 BeanFactoryPostProcessor
。在Spring 应用 context 刷新流程中,我们介绍过这两个接口,它们作为工厂后处理器,被用于 refresh
过程的调用工厂后处理器阶段(invokeBeanFactoryPostProcessors(beanFactory)
)。工厂后处理器的作用,一言以蔽之,允许自定义修改应用上下文中的 bean 定义。
配置类后处理器 ConfigurationClassPostProcessor
的具体作用可以概括为两点:
Bean
,将它们的 bean
定义注册到 BeanFactory
中。根据之前的介绍,进入 invokeBeanFactoryPostProcessors(beanFactory)
,ConfigurationClassPostProcessor
会先作为 BeanDefinitionRegistryPostProcessor
被调用。
--个人的理解是,先将
-BeanFactory
视作BeanDefinitionRegistry
注册好BeanDefinition
,再视作BeanFactory
进行处理,有点预备好原材料再统一处理的意思。
|
--核心方法
-processConfigBeanDefinitions(registry)
冗长,个人建议无需过度关注细节(但同时个人感受是反复阅读和Debug
确实有益于加深理解,看个人时间和精力)。
基于配置类的 BeanDefinition Registry
(也就是 BeanFactory
),获取配置类,构建和校验配置模型:
BeanDefinition Registry
(即 BeanFactory
)中查找配置类。BeanDefinitions
注册到 BeanDefinition Registry
。BeanDefinitions
可能有新的配置类,回到 1
再来一遍。重复循环直到不再引入新的配置类。以本文示例进行说明,静态添加的配置类只有 BeanConfig
,假如 BeanConfig
不仅被 Configuration
注解标注,还被 ComponentScan
注解标注,并且刚好 Spring
通过扫描获得并添加了新的配置类,那么新的配置类就需要继续被解析。
-- - -应正视配置模型这个概念,它可以理解为配置类到
-BeanDefinitions
的中间产物。最初我先入为主,带着解析得到 BeanDefinitions
这样“一阶段”完成的观念,非常不理解processConfigBeanDefinitions
方法上Build and validate a configuration model based on the registry of Configuration classes
这句注释。先行强调注意,处理配置类得到bean
定义分为“两阶段”,解析配置类得到配置模型,从配置模型中读取bean
定义。
public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) { |
public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) { |
checkConfigurationClassCandidate
方法:
Cloudflare Tunnel
访问家庭网络中的服务时,是直接将域名解析到相应服务。尽管 Cloudflare
已经提供相关的请求统计和安全防护功能,部分服务自身也有访问日志,但是为了更好地监控和跟踪对外服务的使用情况,采集 Cloudlfare
统计中缺少的新,决定使用 Nginx
反向代理内部服务,统一内部服务的访问入口。简而言之就是,又折腾一些有的没的。以上修改带来的一个附加好处是在局域网内访问服务时,通过在 hosts
文件中添加域名映射,可以用更加容易记忆的域名代替 IP + port
的形式去访问。
+ SPI
作为一种服务发现机制,允许程序在运行时动态地加载具体实现类。因其强大的可拓展性,SPI
被广泛应用于各类技术框架中,例如 JDBC
驱动、Spring
和 Dubbo
等等。Dubbo
并未使用原生的 Java SPI
,而是重新实现了一套更加强大的 Dubbo SPI
。本文将简单介绍 SPI
的设计理念,通过示例带你体会 SPI
的作用,通过 Dubbo
获取拓展的流程图和源码分析带你理解 Dubbo SPI
的工作原理。深入了解 Dubbo SPI
,你将能更好地利用这一机制为你的程序提供灵活的拓展功能。
----
Cloudflare Tunnel
相较于Zerotier
和OpenVPN
,尽管它们三者都能避免直接开放家庭网络,但前者可以让用户直接使用域名访问到局域网中的服务,便于分享。但它的速度和延迟并不理想,还有人反馈存在网络不稳定的现象,但作为个人玩具还是够用的。有朋友使用公网服务器配合打洞软件和家庭网络中的服务器组网,实现相同目标的同时效果更好。
客户端发起请求,请求经 Cloudflare
转发到局域网中的 Tunnel
。原先,Tunnel
如虚线箭头所示,直接将请求发向目标服务,如今改为发向 Nginx
,由 Nginx
反向代理,发向目标服务。
SPI
的全称是 Service Provider Interface
,是一种服务发现机制。一般情况下,一项服务的接口和具体实现,都是服务提供者编写的。在 SPI
机制中,一项服务的接口是服务使用者编写的,不同的服务提供者编写不同的具体实现。在程序运行时,服务加载器动态地为接口加载具体实现类。因为 SPI
具备“动态加载”的特性,我们很容易通过它为程序提供拓展功能。以 Java
的 JDBC
驱动为例,JDK 提供了 java.sql.Driver
接口,各个数据库厂商,例如 MySQL
、Oracle
提供具体的实现。
Nginx
和 Tunnel
还有其他内部服务应处于同一个网络中。
version: "1.0" |
目前 SPI 的实现方式大多是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类:
+META-INF/services/full.qualified.interface.name
META-INF/dubbo/full.qualified.interface.name
(还有其他目录可供选择)META-INF/spring.factories
定义一个接口 Animal
。
public interface Animal { |
在最后新增了拒绝未匹配成功的域名,在 Cloudflare Tunnel
的使用场景中,其实用处不大,因为未经配置的域名也无法解析到 Nginx
服务。
user nginx; |
定义两个实现类 Dog
和 Cat
。
public class Dog implements Animal { |
本目录下,配置 server 块。
-server { |
在 META-INF/services
文件夹下创建一个文件,名称为 Animal
的全限定名 com.moralok.dubbo.spi.test.Animal
,文件内容为实现类的全限定名,实现类的全限定名之间用换行符分隔。
com.moralok.dubbo.spi.test.Dog |
代理(Proxy)也称为网络代理,是一种特殊的网络服务,允许一个终端通过这个服务与另一个终端进行非直接的连接。一般认为代理服务有利于保障网络终端的隐私或安全,在一定程度上能够阻止网络攻击。
- +进行测试。
+public class JavaSPITest { |
反向代理(Reverse Proxy)在电脑网络中是代理服务器的一种。服务器根据客户端的请求,从其关联的一组或多组后端服务器上获取资源,然后再将这些资源返回给客户端,客户端只会得知反向代理的 IP
地址,而不知道在代理服务器后面的服务器集群的存在。
测试结果
+Java SPI |
Dubbo
并未使用原生的 Java SPI
,而是重新实现了一套功能更加强大的 SPI
机制。Dubbo SPI
的配置文件放在 META-INF/dubbo
文件夹下,名称仍然是接口的全限定名,但是内容是“名称->实现类的全限定名”的键值对,另外接口需要标注 SPI
注解。
dog = com.moralok.dubbo.spi.test.Dog |
进行测试。
+public class DubboSPITest { |
测试结果
+Dubbo SPI |
private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<>(64); |
这个方法包含了如下步骤:
+EXTENSION_LOADERS
中获取与拓展类对应的 ExtensionLoader
,如果缓存未命中,则创建一个新的实例,保存到缓存并返回。++“从缓存中获取,如果缓存未命中,则创建,保存到缓存并返回”,类似的
+getOrCreate
的处理模式在Dubbo
的源码中经常出现。
EXTENSION_LOADERS
是 ExtensionLoader
的静态变量,保存了“拓展类->ExtensionLoader
”的映射关系。
public T getExtension(String name) { |
这个方法中获取 Holder
和获取拓展实例都是使用 getOrCreate
的模式。
Holder
用于持有拓展实例。cachedInstances
是 ExtensionLoader
的成员变量,保存了“name->Holder
(拓展实例)”的映射关系。
private T createExtension(String name, boolean wrap) { |
这个方法包含如下步骤:
+getExtensionClasses
获取所有拓展类Wrapper
对象中第一步是加载拓展类的关键,第三步和第四步是 Dubbo IOC
和 AOP
的具体实现。
最后拓展实例的结构如下图。
+ + +private Map<String, Class<?>> getExtensionClasses() { |
代码参考旧版本更容易理解。处理过程在本质上就是依次加载 META-INF/dubbo/internal/
、META-INF/dubbo/
、META-INF/services/
三个目录下的配置文件,获取拓展类。
loadDirectory(extensionClasses, DUBBO_INTERNAL_DIRECTORY); |
新版本使用原生的 Java SPI
加载 LoadingStrategy
,允许用户自定义加载策略。
DubboInternalLoadingStrategy
,目录 META-INF/dubbo/internal/
,优先级最高DubboLoadingStrategy
,目录 META-INF/dubbo/
,优先级普通ServicesLoadingStrategy
,目录 META-INF/services/
,优先级最低private static volatile LoadingStrategy[] strategies = loadLoadingStrategies(); |
LoadingStrategy
的 Java SPI
配置文件
loadDirectory
方法先通过 classLoader
获取所有的资源链接,然后再通过 loadResource
方法加载资源。
新版本中 extensionLoaderClassLoaderFirst
可以设置是否优先使用 ExtensionLoader's ClassLoader
获取资源链接。
private void loadDirectory(Map<String, Class<?>> extensionClasses, String dir, String type, |
loadResource
方法用于读取和解析配置文件,并通过反射加载类,最后调用 loadClass
方法进行其他操作。loadClass
方法用于操作缓存。
新版本中 excludedPackages
可以设置将指定包内的类都排除。
private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader, |
loadClass
方法设置了多个缓存,比如 cachedAdaptiveClass
、cachedWrapperClasses
、cachedNames
和 cachedClasses
。
新版本中 overridden
可以设置是否覆盖 cachedAdaptiveClass
、cachedClasses
的 name->clazz
。
private void loadClass(Map<String, Class<?>> extensionClasses, java.net.URL resourceURL, Class<?> clazz, String name, |
Dubbo IOC
是通过 setter
方法注入依赖。Dubbo
首先通过反射获取目标类的所有方法,然后遍历方法列表,检测方法名是否具有 setter
方法特征并满足条件,若有,则通过 objectFactory
获取依赖对象,最后通过反射调用 setter
方法将依赖设置到目标对象中。
++与
+Spring IOC
相比,Dubbo IOC
实现的依赖注入功能更加简单,代码也更加容易理解。
private T injectExtension(T instance) { |
objectFactory
是 ExtensionFactory
的自适应拓展,通过它获取依赖对象,本质上是根据目标拓展类获取 ExtensionLoader
,然后获取其自适应拓展,过程代码如下。具体我们不再深入分析,可以参考Dubbo SPI 自适应拓展的工作原理。
public <T> T getExtension(Class<T> type, String name) { |
Cloudflare Tunnel
访问家庭网络中的服务时,是直接将域名解析到相应服务。尽管 Cloudflare
已经提供相关的请求统计和安全防护功能,部分服务自身也有访问日志,但是为了更好地监控和跟踪对外服务的使用情况,采集 Cloudlfare
统计中缺少的新,决定使用 Nginx
反向代理内部服务,统一内部服务的访问入口。简而言之就是,又折腾一些有的没的。以上修改带来的一个附加好处是在局域网内访问服务时,通过在 hosts
文件中添加域名映射,可以用更加容易记忆的域名代替 IP + port
的形式去访问。
+
+
++++
Cloudflare Tunnel
相较于Zerotier
和OpenVPN
,尽管它们三者都能避免直接开放家庭网络,但前者可以让用户直接使用域名访问到局域网中的服务,便于分享。但它的速度和延迟并不理想,还有人反馈存在网络不稳定的现象,但作为个人玩具还是够用的。有朋友使用公网服务器配合打洞软件和家庭网络中的服务器组网,实现相同目标的同时效果更好。
客户端发起请求,请求经 Cloudflare
转发到局域网中的 Tunnel
。原先,Tunnel
如虚线箭头所示,直接将请求发向目标服务,如今改为发向 Nginx
,由 Nginx
反向代理,发向目标服务。
Nginx
和 Tunnel
还有其他内部服务应处于同一个网络中。
version: "1.0" |
在最后新增了拒绝未匹配成功的域名,在 Cloudflare Tunnel
的使用场景中,其实用处不大,因为未经配置的域名也无法解析到 Nginx
服务。
user nginx; |
本目录下,配置 server 块。
+server { |
代理(Proxy)也称为网络代理,是一种特殊的网络服务,允许一个终端通过这个服务与另一个终端进行非直接的连接。一般认为代理服务有利于保障网络终端的隐私或安全,在一定程度上能够阻止网络攻击。
+ + +反向代理(Reverse Proxy)在电脑网络中是代理服务器的一种。服务器根据客户端的请求,从其关联的一组或多组后端服务器上获取资源,然后再将这些资源返回给客户端,客户端只会得知反向代理的 IP
地址,而不知道在代理服务器后面的服务器集群的存在。
Nginx
没有提供开箱即用的日志滚动功能,而是将其交给使用者自己实现。你既可以按照官方文档的建议通过编写脚本实现,也可以使用 logrotate
管理日志。但是和在普通场景下不同,在使用 Docker
运行 Nginx
时,你可能需要额外考虑一点细节。本文记录了在为 Docker
中的 Nginx
的日志文件配置滚动功能过程中遇到的一些问题和思考。
+ Dubbo SPI
自适应拓展是什么样子,是一种非常好的表现其作用的方式。正如官方博客中所说的,它让人对自适应拓展有更加感性的认识,避免读者一开始就陷入复杂的代码生成逻辑。本文在此基础上,从更原始的使用方式上展现“动态加载”技术对“按需加载”的天然倾向,从更普遍的角度解释自适应拓展的本质目的,在介绍 Dubbo
的具体实现是如何约束自身从而规避缺点之后,详细梳理了 Dubbo SPI
自适应拓展的相关源码和工作原理。
---In order to rotate log files, they need to be renamed first. After that USR1 signal should be sent to the master process. The master process will then re-open all currently open log files and assign them an unprivileged user under which the worker processes are running, as an owner. After successful re-opening, the master process closes all open files and sends the message to worker process to ask them to re-open files. Worker processes also open new files and close old files right away. As a result, old files are almost immediately available for post processing, such as compression.
-
根据官方文档的解释,滚动日志文件的流程应如下,你可以自己编写 Shell
脚本配合 crontab
实现定时滚动功能。
USR1
信号给 Nginx
主进程,Nginx
将重新打开日志文件mv access.log access.log.0 |
kill
命令前,即便已经重命名了日志文件,Nginx
还是会向重命名后的文件写入日志。因为在 Linux
系统中,系统内核是根据文件描述符定位文件的。USR1
是自定义信号,软件的作者自己确定收到该信号后做什么。在 Nginx
中,主进程收到信号后,会重新打开所有当前打开的日志文件并将它们分配给一个非特权用户作为所有者,工作进程就是在该所有者下运行的。成功重新打开后,主进程关闭所有打开的文件并向工作进程发送消息,要求它们重新打开文件。工作进程也打开新文件并立即关闭旧文件。-logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
++-站在现有设计回头看的视角更偏向于展现为什么这样设计很好,却并不好展现如果不这样设计会有什么问题,以至于有时候会有种这个设计很妙,但妙在哪里体会不够深的感觉。思考一项技术如何从最初发展到现在,解决以及试图解决哪些问题,因此可能引入哪些问题,也许脑补的并不完全符合历史事实,但仍然会让人更加深刻地认识这项技术本身,体会设计中的巧思,并避免一直陷在庞杂的细节处理中。
-
logrotate
旨在简化生成大量日志文件的系统的管理。它允许自动滚动、压缩、删除和邮寄日志文件。每个日志文件可以在每天、每周、每月或当它变得太大时处理。Linux
一般默认安装了logrotate
。默认配置文件
查看默认配置文件:
-cat /etc/logrotate.conf
。+
# see "man logrotate" for details
# 每周滚动日志文件
weekly
# 默认使用 adm group,因为这是 /var/log/syslog 的所属组
su root adm
# 保留 4 周的备份(其实是保留 4 个备份,对应 weekly 的设置,就是保留 4 周)
rotate 4
# 在滚动旧日志文件后,创建新的空日志文件
create
# 使用日期作为滚动日志文件的后缀
#dateext
# 如果你希望压缩日志文件,请取消注释
#compress
# 软件包将日志滚动的配置信息放入此目录中
include /etc/logrotate.d
# system-specific logs may be also be configured here.原理
在
+Dubbo
中,很多拓展都是通过SPI
机制动态加载的,比如Protocol
、Cluster
和LoadBalance
等。有些拓展我们并不想在框架启动阶段被加载,而是希望在拓展方法被调用时,根据运行时参数进行加载。为了让大家对自适应拓展有一个感性的认识,下面我们通过一个实例进行演示。示例
定义一个接口
+Animal
。-
public interface Animal {
void bark();
}配置信息所在目录
查看日志滚动的配置信息所在的目录:
-ls /etc/logrotate.d/
。+
alternatives apport apt bootlog btmp dpkg rsyslog ubuntu-advantage-tools ufw unattended-upgrades wtmp定义两个实现类
+Dog
和Cat
。-
public class Dog implements Animal {
public void bark() {
System.out.println("Dog bark...");
}
}
public class Cat implements Animal {
public void bark() {
System.out.println("Cat bark...");
}
}为 Nginx 新增配置
为
-Nginx
新增日志滚动配置,vim /etc/logrotate.d/nginx
。+
/path/to/your/nginx/logs/*.log {
# 切换用户
su moralok moralok
# 每天滚动日志文件
daily
# 使用日期作为滚动日志文件的后缀
dateext
# 如果日志丢失,不报错继续滚动下一个日志
missingok
# 保留 31 个备份
rotate 31
# 不压缩
nocompress
# 整个日志组运行一次的脚本
sharedscripts
# 滚动后的处理
postrotate
# 重新打开日志文件
docker exec nginx sh -c "[ ! -f /var/run/nginx.pid ] || (kill -USR1 `docker exec nginx cat /var/run/nginx.pid`; echo 'Successfully rotating nginx logs.')"
endscript
}在运行时根据参数动态地加载拓展。
+-
public void bark(String type) {
if (type == null) {
throw new IllegalArgumentException("type == null");
}
// 通过 SPI 动态地加载具体的 Animal
Animal animal = ExtensionLoader.getExtensionLoader(Animal.class).getExtension(type);
// 调用目标方法
animal.bark();
}验证配置和测试
测试配置文件是否有错误,
-logrotate -d /etc/logrotate.d/nginx
。强制滚动:
-logrotate -f /etc/logrotate.d/nginx
一些坑
/var/lib/logrotate/ 权限问题
当你使用校验过配置文件的正确性后,尝试强制滚动时,可能会遇到报错。
-+
logrotate -f /etc/logrotate.d/nginx
error: error creating output file /var/lib/logrotate/status.tmp: Permission denied改进
是不是感觉平平无奇?没错,当你拥有动态加载的能力后,按需加载是自然而然会产生的想法,并不是什么高大上的设计。两者甚至不仅仅是天性相合,可能更像是你中有我,我中有你。在正常场景中,这样一段代码也并不需要进一步被抽象和重构,它本身就很简洁。现在设想一下,你的应用中,有大量的拓展需要动态加载,你可能需要在很多地方写很多根据运行时参数动态加载拓展并调用方法的代码,就像下面这样:
+-
Animal animal = ExtensionLoader.getExtensionLoader(Animal.class).getExtension(type);
animal.bark();
WeelMaker weelMaker = ExtensionLoader.getExtensionLoader(WeelMaker.class).getExtension(weelMakerName);
weelMaker.makeWeel();
LoadBalance loadBalance = ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(invocation.getLoadBalanceType());
loadBalance.select();
// ...这是因为 logrotate 会在
-/var/lib/logrotate/
目录下创建status
文件。查看目录权限可知,需要以root
身份运行logrotate
。+
ll /var/lib/logrotate
total 12
drwxr-xr-x 2 root root 4096 Dec 2 08:35 ./
drwxr-xr-x 44 root root 4096 Jun 26 09:02 ../
-rw-r--r-- 1 root root 1395 Dec 2 08:35 status这会带来一些小问题,总是需要写
+ExtensionLoader.getExtensionLoader(XXX.class).getExtension(parameter)
这样重复的代码;引入了ExtensionLoader
这个“中介”,不能直面拓展本身。后者可能有点难以体会,以动物园Zoo
和 动物Animal
举例。在非动态加载情况下,我们可能会这样写:
+-
public class Zoo {
private List<Animal> animals;
public void bark(String type) {
for (Animal animal : animals) {
if (type.equals(animal.name)) {
animal.bark();
}
}
}
}事实上,
-logrotate -d /etc/logrotate.d/nginx
命令也会读取/var/lib/logrotate/status
,但是other
对该目录也有r
读取权限,所以没有报错。+
logrotate -d /etc/logrotate.d/nginx
WARNING: logrotate in debug mode does nothing except printing debug messages! Consider using verbose mode (-v) instead if this is not what you want.
reading config file /etc/logrotate.d/nginx
Reading state from file: /var/lib/logrotate/status
...在动态加载情况下,我们可能会这样写。在这种情况下,
+Zoo
没有合适的方式直接持有Animal
,而是通过ExtensionLoader
间接地持有。-
public class Zoo {
private ExtensionLoader<Animal> extensionLoader = ExtensionLoader.getExtensionLoader(Animal.class);
public void bark(String type) {
Animal animal = extensionLoader.getExtension(type);
animal.bark();
}
}日志文件夹的权限
即使你使用
-root
身份运行logrotate
,你可能还会遇到以下报错+
logrotate -f /etc/logrotate.d/nginx
error: skipping "/path/to/your/nginx/logs/*.log" because parent directory has insecure permissions (It's world writable or writable by group which is not "root") Set "su" directive in config file to tell logrotate which user/group should be used for rotation.我们更想要以下这种直接持有
+Animal
的方式,在运行时animal
可以是Dog
,也可以是Cat
,还可以是其他的动物。-
public class Zoo {
private Animal animal;
public void bark(String type) {
animal.bark();
}
}你需要在配置文件中,使用
-su <user> <group>
指定日志所在文件夹所属的用户和组,logrotate
才能正确读写。由宿主机还是容器主导
首先
-Nginx
的日志文件夹通过挂载映射到宿主机,日志滚动既可以由宿主机主导,也可以由容器主导,不过不论如何我们都需要向Docker
容器内的Nginx
发送USR1
信号。有人倾向于在容器内完成所有工作,和宿主机几乎完全隔离;我个人更青睐于由宿主机主导,因为容器内的环境并不总是拥有你想要使用的软件(除非你总是定制自己使用的镜像),甚至标准镜像往往非常精简。在
-logrotate
配置中的postrotate
部分添加脚本,使用docker exec
在容器内执行命令,完成向Nginx
发送信号的工作。脚本的处理逻辑大概是“如果存在/var/run/nginx.pid
,就执行kill -USR1 \`cat /var/run/nginx.pid\`
命令,并打印成功的消息”。但是我看到很多文章中分享的配置类似下面这样:+
docker exec nginx sh -c "if [ -f /var/run/nginx.pid ]; then kill -USR1 $(docker exec nginx cat /var/run/nginx.pid); fi"
docker exec nginx sh -c "[ ! -f /var/run/nginx.pid ] || kill -USR1 `cat /var/run/nginx.pid`;"+
Dubbo
采用了一种称为“自适应拓展”的巧妙设计,通过代理的方式,将动态加载拓展的代码整合到代理类(具体实现类)中。使用方调用代理对象,代理对象根据参数动态加载拓展并调用。例如Animal
的自适应拓展,就像下面这样:-
public class AdaptiveAnimal implements Animal {
public void bark(String type) {
if (type == null) {
throw new IllegalArgumentException("type == null");
}
Animal animal = ExtensionLoader.getExtensionLoader(Animal.class).getExtension(type);
animal.bark();
}
}
Animal animal = new AdaptiveAnimal();
animal.bark(type);经过测试都会有以下报错,我不清楚是否大多是抓取发布的文章,也不清楚他们是否测试过,对于
-Shell
脚本写得不多的我来说,半夜测试反复排查错误真是头昏脑胀。在我原先的理解里,-c
后面的脚本是整体发到容器内部执行的,后来我才意识到,我对脚本内部的命令在宿主机还是容器里执行的理解是错误的。+
cat: /var/run/nginx.pid: No such file or directory当然,我们不希望需要手动地为每一个拓展编写
+Adaptive
代理类,事实上,我们以往接触到的代理方案,大都是自动生成代理的,应该也不会有人会接受完全手写的方式。然而你可能会注意到一个不够和谐的缺点,bark
方法的参数列表中新增了type
类型,这不太符合面向对象的设计原则。想象一个更奇怪的场景,我们要为一个方法引入与它本身格格不入的参数用于获取拓展。另外,我们可能需要通过一些标记或约定来告诉代理生成器,方法参数列表中哪一个参数是用于获取拓展的。事实上,Dubbo
的另一个设计规避了这一缺点,Dubbo
在公共契约中提到:所有扩展点参数都包含URL
参数,URL
作为上下文信息贯穿整个扩展点设计体系。因此围绕着Dubbo
以URL
为中心的拓展体系,你很难设计出Animal.bark(URL url)
这样不和谐的方法签名,也不用担心参数列表千奇百怪的情况。同时Dubbo
并未完全抛弃手工编写自适应拓展的方式,而是予以保留。手工编码的自适应拓展
在在
+Dubbo
中,尽管很少但仍然存在手工编码的自适应拓展,这类拓展允许你不使用URL
作为参数,查看它们的代码可以帮助我们更好地理解自适应拓展是如何在真实的应用场景中发挥作用的。以下是ExtensionFactory
的自适应拓展,当你调用它的getExtension
方法时,它就是将工作全权委托给factory.getExtension(type, name)
完成的,而factories
在创建AdaptiveExtensionFactory
时就已经获取了。-
public class AdaptiveExtensionFactory implements ExtensionFactory {
private final List<ExtensionFactory> factories;
public AdaptiveExtensionFactory() {
// 获取 ExtensionFactory 的 ExtensionLoader
ExtensionLoader<ExtensionFactory> loader = ExtensionLoader.getExtensionLoader(ExtensionFactory.class);
List<ExtensionFactory> list = new ArrayList<ExtensionFactory>();
// 获取全部支持的(不包含自适应拓展)拓展名称,依次获取拓展加入 factories
for (String name : loader.getSupportedExtensions()) {
list.add(loader.getExtension(name));
}
factories = Collections.unmodifiableList(list);
}
public <T> T getExtension(Class<T> type, String name) {
for (ExtensionFactory factory : factories) {
// 委托给其他 ExtensionFactory 拓展获取,比如 SpiExtensionFactory
T extension = factory.getExtension(type, name);
if (extension != null) {
return extension;
}
}
return null;
}
}继续写入旧日志文件
在使用
-logrotate -f /etc/logrotate.d/nginx
测试通过后的第二天,发现虽然创建了新的日志文件,但是Nginx
继续写入到旧的日志文件。这不同于网上很多文章提到的“没有发送USR1
信号给Nginx
”的情况。尝试手动发送信号,观察效果。
-+
docker exec nginx sh -c "kill -USR1 `docker exec nginx cat /var/run/nginx.pid`"至此,我们提到了按需加载是具备动态加载能力后自然的倾向,介绍了在拥有大量拓展情况下演变而来的自适应拓展设计,它的缺点和 Dubbo 是如何规避的。接下来,我们将进入源码分析部分。
+源码分析
Adaptive 注解
+
Adaptive
注解是一个与自适应拓展息息相关的注解,该定义如下:-
public Adaptive {
String[] value() default {};
}发现虽然终止了继续写入到旧的文件,但是在宿主机读取日志时,提示没有权限。
-+
cat access.log
cat: access.log: Permission denied根据
+Target
注解的value
可知,Adaptive
注解可标注在类或者方法上。当Adaptive
注解标注在类上时,Dubbo
不会为该类生成代理类。当Adaptive
注解标注在接口方法上时,Dubbo
则会为该方法生成代理逻辑。Adaptive
注解在类上的情况很少,在Dubbo
中,仅有两个类被Adaptive
注解标注,分别是AdaptiveCompiler
和AdaptiveExtensionFactory
。在这种情况下,拓展的加载逻辑由人工编码完成。在更多时候,Adaptive
注解是标注在接口方法上的,这表示拓展的加载逻辑需由框架自动生成。获取自适应拓展
获取自适应拓展的入口方法是
+getAdaptiveExtension
,使用getOrCreate
的模式获取。-
public T getAdaptiveExtension() {
// 双重检查
// 从缓存中获取自适应拓展
Object instance = cachedAdaptiveInstance.get();
if (instance == null) {
// 创建自适应拓展失败的结果也会被缓存,避免重复尝试
if (createAdaptiveInstanceError != null) {
throw new IllegalStateException("Failed to create adaptive instance: " +
createAdaptiveInstanceError.toString(),
createAdaptiveInstanceError);
}
synchronized (cachedAdaptiveInstance) {
instance = cachedAdaptiveInstance.get();
if (instance == null) {
try {
// 创建自适应拓展
instance = createAdaptiveExtension();
// 将自适应拓展设置到缓存中
cachedAdaptiveInstance.set(instance);
} catch (Throwable t) {
createAdaptiveInstanceError = t;
throw new IllegalStateException("Failed to create adaptive instance: " + t.toString(), t);
}
}
}
}
return (T) instance;
}查看日志文件权限,发现不对劲。新建的
+access.log
的权限为600
,所属用户从moralok
变为systemd-resolve
,失去了只有所属用户才拥有的rw
权限。创建自适应拓展
当缓存为空时,就会通过
+createAdaptiveExtension
方法创建。方法包含以下三个处理逻辑:+
- 调用
+getAdaptiveExtensionClass
方法获取自适应拓展的Class
对象。- 通过反射进行实例化。
+- 调用
+injectExtension
方法对拓展实例进行依赖注入。--此时虽然注意到没有成功创建新的
+error.log
,但是只是以为对于空的日志文件不滚动。并且在后续重现问题时发现此时其实可以在容器里看到日志开始写入新的日志文件。手工编码的自适应拓展可能依赖其他拓展,但是框架生成的自适应拓展并不依赖其他拓展。
+
ll
total 136
drwxrwxr-x 2 moralok moralok 4096 Dec 3 00:00 ./
drwxrwxr-x 4 moralok moralok 4096 Nov 30 17:25 ../
-rw------- 1 moralok moralok 0 Dec 3 00:00 access.log
-rw-r--r-- 1 systemd-resolve root 109200 Dec 3 07:01 access.log-20231203
-rw-r--r-- 1 systemd-resolve root 0 Dec 2 19:07 error.log-
private T createAdaptiveExtension() {
try {
return injectExtension((T) getAdaptiveExtensionClass().newInstance());
} catch (Exception e) {
throw new IllegalStateException("Can't create adaptive extension " + type + ", cause: " + e.getMessage(), e);
}
}这个时候我的想法是,既然成功创建了新的日志文件,肯定是
-Nginx
接收到了USR1
信号。怎么会出现“crontab
触发时发送信号有问题,手动发送却没问题”的情况呢?难道是两者触发的执行方式有所不同?还是说宿主机创建的文件会有问题?注意到新的日志文件所属的用户和组和原日志文件所属的用户和组不同,我开始怀疑创建文件的过程有问题。在反复测试尝试重现问题后,我把关注点放到了create
配置上。其实在最开始,我就关注了它,我想既然在默认的配置文件中已经设置而且我也不修改,那么就不在/etc/logrotate.d/nginx
添加了。我甚至花了很多时间浏览文档,确认它在缺省后面的权限属性时会使用原日志文件的权限属性。当时我还专门记录了一个疑问,“文档说在运行postrotate
脚本前创建新文件,可是在测试验证时,新文件是Nginx
接收USR1
信号后重新打开文件时创建的,在脚本执行报错或者脚本中并不发送信号时,不会产生新文件”。现在想来,都是坑,坑里注定要灌满眼泪!在
-/etc/logrotate.d/nginx
添加create
后,成功重现问题。+
...
renaming /path/to/your/nginx/logs/access.log to /path/to/your/nginx/logs/access.log-20231203
creating new /path/to/your/nginx/logs/access.log mode = 0644 uid = 101 gid = 0
error: error setting owner of /path/to/your/nginx/logs/access.log to uid 101 and gid 0: Operation not permitted
switching euid to 0 and egid to 0获取自适应拓展类
获取自适应拓展类的
+getAdaptiveExtensionClass
方法包含以下三个处理逻辑:+
+- 通过
+getExtensionClasses
方法获取所有拓展类。- 检查缓存
+cachedAdaptiveClass
,如果不为null
,则返回缓存。- 如果缓存为
+null
,则调用createAdaptiveExtensionClass
创建自适应拓展类(代理类)。在Dubbo SPI 的工作原理中我们分析过
+getExtensionClasses
方法,在获取拓展的所有实现类时,如果某个实现类被Adaptive
注解标注了,那么该类就会被赋值给cachedAdaptiveClass
变量。“原理”部分介绍的AdaptiveExtensionFactory
就属于这种情况,我们不再细谈。按前文所说,在绝大多数情况下,Adaptive
注解都是用于标注方法而非标注具体的实现类,因此在大多数情况下程序都会走第三个步骤,由框架自动生成自适应拓展类(代理类)。-
private Class<?> getAdaptiveExtensionClass() {
getExtensionClasses();
if (cachedAdaptiveClass != null) {
return cachedAdaptiveClass;
}
return cachedAdaptiveClass = createAdaptiveExtensionClass();
}可见
logrotate -f /etc/logrotate.d/nginx
,并没有使用到默认配置/etc/logrotate.conf
,而crontab
触发logrotate
时使用到了。修改为create 0644 moralok moralok
,成功解决问题。可以确认create
在缺省权限属性的时候,如果日志文件因为挂载到容器中而被修改了所属用户,logrotate
按照原文件的权限属性创建新文件时会报错,从而导致脚本不能正常执行,Nginx
不会收到信号,error.log
也不会继续滚动。按此原因推理,在配置文件中,加入nocreate
也可以解决问题,并且更加符合官方文档建议的流程。--枯坐一下午,百思不得其解,想到抓狂。不得不说真的很讨厌这类问题,特定条件下奇怪的问题,食之无味,弃之还不行!如果照着网上的文章,一开始就添加配置真的不会遇到啊。可是不喜欢不明不白地修改配置来解决问题,也不喜欢一次性加很多设置却不知道各个配置的功能,特别是在我的理解里这个默认配置似乎没问题的情况下。明明想要克制住不知重点还不断深入探索细节的坏习惯,却还是被一个
+Bug
带着花费了大量的时间和精力,解决了一个照着抄就不会遇到的问题。虽然真的有收获,真的解决了前一晚留下的疑问,可是不甘心啊,气气气!而且为什么这样会有问题,我还是不懂!到目前为止,获取自适应拓展的过程和获取普通拓展的过程是非常相似的,使用
getOrCreate
的模式获取拓展,如果缓存为空则创建,创建的时候会先加载全部的拓展实现类,从中获取目标类,通过反射进行实例化,最后进行依赖注入。区别在于获取目标类时,在自适应拓展情况下,返回的可能是一个生成的代理类。生成的过程非常复杂,是我们接下来关注的重点。logrotate 备忘
-命令参数
+
-d, --debug : 打开调试模式,这意味着不会对日志进行任何更改,并且 logrotate 状态文件不会更新。仅打印调试消息。
-f, --force : 告诉 logrotate 强制滚动,即使它认为这没有必要。有时,在将新条目添加到 logrotate 配置文件后,或者如果已手动删除旧日志文件,这会很有用,因为将创建新文件,并且正确地继续记录日志。
-m, --mail <command> : 告诉 logrotate 在邮寄日志时使用哪个命令。此命令应接受两个参数:消息的主题,收件人。然后,该命令必须读取标准输入上的消息并将其邮寄给收件人。默认邮件命令是 /bin/mail -s。
-s, --state <statefile> : 告诉 logrotate 使用备用状态文件。如果 logrotate 以不同用户身份运行不同的日志文件集,这会非常有用。默认状态文件是 /var/lib/logrotate/status。
--usage : 打印简短的用法信息。
--?, --help : 打印帮助信息。
-v, --verbose : 打开详细模式,例如在滚动期间显示消息。生成自适应拓展类
生成自适应拓展类的方式相比于以往接触的生成代理类的方式更加“直观且容易理解”,但是相应的,拼接字符串部分的代码并不容易阅读。
++
+- 通过拼接字符串得到代理类的源码。
+- 使用编译器编译得到
+Class
对象。++在新版本中,这部分代码的可读性有了非常大的提升,原先冗长的处理逻辑被抽象为多个命名含义清晰的方法。
+-
private Class<?> createAdaptiveExtensionClass() {
// 区别于旧版本:新版本抽象出一个 AdaptiveClassCodeGenerator 用于生成代码
String code = new AdaptiveClassCodeGenerator(type, cachedDefaultName).generate();
ClassLoader classLoader = findClassLoader();
// 获取编译器拓展
org.apache.dubbo.common.compiler.Compiler compiler =
ExtensionLoader.getExtensionLoader(org.apache.dubbo.common.compiler.Compiler.class).getAdaptiveExtension();
// 编译代码,生成 Class 对象
return compiler.compile(code, classLoader);
}常用配置文件参数
- -
- - -参数 -说明 -- -daily -周期:每天 -- -weekly -周期:每周 -- monthly +为了更直观地了解代码生成的效果及其实现的功能,以
+Protocol
为例,生成的完整代码(已经经过格式化)展示如下。+ +
package org.apache.dubbo.rpc;
import org.apache.dubbo.common.extension.ExtensionLoader;
public class Protocol$Adaptive implements org.apache.dubbo.rpc.Protocol {
public void destroy() {
throw new UnsupportedOperationException(
"The method public abstract void org.apache.dubbo.rpc.Protocol.destroy() of interface org.apache.dubbo.rpc.Protocol is not adaptive method!");
}
public int getDefaultPort() {
throw new UnsupportedOperationException(
"The method public abstract int org.apache.dubbo.rpc.Protocol.getDefaultPort() of interface org.apache.dubbo.rpc.Protocol is not adaptive method!");
}
public org.apache.dubbo.rpc.Invoker refer(java.lang.Class arg0, org.apache.dubbo.common.URL arg1)
throws org.apache.dubbo.rpc.RpcException {
if (arg1 == null)
throw new IllegalArgumentException("url == null");
org.apache.dubbo.common.URL url = arg1;
String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());
if (extName == null)
throw new IllegalStateException("Failed to get extension (org.apache.dubbo.rpc.Protocol) name from url ("
+ url.toString() + ") use keys([protocol])");
org.apache.dubbo.rpc.Protocol extension = (org.apache.dubbo.rpc.Protocol) ExtensionLoader
.getExtensionLoader(org.apache.dubbo.rpc.Protocol.class).getExtension(extName);
return extension.refer(arg0, arg1);
}
public java.util.List getServers() {
throw new UnsupportedOperationException(
"The method public default java.util.List org.apache.dubbo.rpc.Protocol.getServers() of interface org.apache.dubbo.rpc.Protocol is not adaptive method!");
}
public org.apache.dubbo.rpc.Exporter export(org.apache.dubbo.rpc.Invoker arg0)
throws org.apache.dubbo.rpc.RpcException {
if (arg0 == null)
throw new IllegalArgumentException("org.apache.dubbo.rpc.Invoker argument == null");
if (arg0.getUrl() == null)
throw new IllegalArgumentException("org.apache.dubbo.rpc.Invoker argument getUrl() == null");
org.apache.dubbo.common.URL url = arg0.getUrl();
String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());
if (extName == null)
throw new IllegalStateException("Failed to get extension (org.apache.dubbo.rpc.Protocol) name from url ("
+ url.toString() + ") use keys([protocol])");
org.apache.dubbo.rpc.Protocol extension = (org.apache.dubbo.rpc.Protocol) ExtensionLoader
.getExtensionLoader(org.apache.dubbo.rpc.Protocol.class).getExtension(extName);
return extension.export(arg0);
}
}生成的代理类需完成以下功能:
++
+- 非
+adaptive
方法,直接抛出异常。- +
adaptive
方法:+
+- 准备工作:在参数判空校验之后,从中获取到
+URL
对象,结合URL
对象和默认拓展名得到最终的拓展名extName
。- 核心功能:先获取拓展的
+ExtensionLoader
,再根据拓展名extName
获取拓展,最后调用拓展的同名方法。以上的功能在表面上看来并不复杂,事实上,想要实现的目标处理逻辑也并不复杂,只在为了提供足够的可扩展性,具体实现变得很复杂。复杂的处理逻辑主要集中在如何为“准备工作”部分生成相应的代码,大概可以总结为:在获取拓展前,
+Dubbo
会直接或间接地从参数列表中查找URL
对象,所谓直接就是URL
对象直接在参数列表中,所谓间接就是URL
对象是其中一个参数的属性。在得到URL
对象后,Dubbo
会尝试以Adaptive
注解的value
为key
,从URL
中获取值作为拓展名,如果获取不到则使用默认拓展名defaultExtName
。实际的实现更加复杂,需要耐心阅读和测试。自适应拓展类代码生成器
新版本将代码生成的逻辑抽象到自适应拓展类代码生成器中,注意参数只有
+type
和defaultExtName
,从这里也可以看出如何确定最终加载的拓展,取决于这两个参数和被调用方法的入参。+ +
public AdaptiveClassCodeGenerator(Class<?> type, String defaultExtName) {
this.type = type;
this.defaultExtName = defaultExtName;
}
public String generate() {
// 检测是否至少存在一个方法标注了 Adaptive 注解
if (!hasAdaptiveMethod()) {
throw new IllegalStateException("No adaptive method exist on extension " + type.getName() + ", refuse to create the adaptive class!");
}
// 区别于旧版本:抽象为几个命名含义清晰的方法,提升了可读性
// 生成类:包名、导入、类声明
StringBuilder code = new StringBuilder();
code.append(generatePackageInfo());
code.append(generateImports());
code.append(generateClassDeclaration());
// 生成方法
Method[] methods = type.getMethods();
for (Method method : methods) {
code.append(generateMethod(method));
}
code.append("}");
if (logger.isDebugEnabled()) {
logger.debug(code.toString());
}
return code.toString();
}检测 Adaptive 注解
在生成代理类源码之前,
+generate
方法会先通过反射检测接口方法中是否至少有一个标注了Adaptive
注解,若不满足,就会抛出异常。++流式编程使用得当的话很有可读性啊。
++ +
private boolean hasAdaptiveMethod() {
return Arrays.stream(type.getMethods()).anyMatch(m -> m.isAnnotationPresent(Adaptive.class));
}生成类
生成代理类源码的顺序和普通
+Java
类文件中内容的顺序一致:+
+- package
+- import
+- 类声明
+先忽略“生成方法”的部分,以
+Dubbo
的Protocol
拓展为例,生成的代码如下:+ +
package org.apache.dubbo.rpc;
import org.apache.dubbo.common.extension.ExtensionLoader;
public class Protocol$Adaptive implements org.apache.dubbo.rpc.Protocol {
// 省略方法代码
}生成方法
生成方法的过程同样被抽象为几个命名含义清晰的方法,包含以下五个部分:
++
+- 返回值
+- 方法名
+- 方法内容
+- 方法参数
+- 方法抛出的异常
++ +
private String generateMethod(Method method) {
String methodReturnType = method.getReturnType().getCanonicalName();
String methodName = method.getName();
String methodContent = generateMethodContent(method);
String methodArgs = generateMethodArguments(method);
String methodThrows = generateMethodThrows(method);
return String.format(CODE_METHOD_DECLARATION, methodReturnType, methodName, methodArgs, methodThrows, methodContent);
}除了最重要的“方法内容”部分,其他部分都是复制原方法的信息,并不复杂。生成“方法内容”部分,分为是否被
+Adaptive
注解标注。+ +
private String generateMethodContent(Method method) {
Adaptive adaptiveAnnotation = method.getAnnotation(Adaptive.class);
StringBuilder code = new StringBuilder(512);
// 检测方法是否被 Adaptive 注解标注
if (adaptiveAnnotation == null) {
return generateUnsupported(method);
} else {
// ...
}
return code.toString();
}无 Adaptive 注解标注的方法
对于无
+Adaptive
注解标注的方法,生成逻辑很简单,就是生成抛出异常的代码。+ +
private String generateUnsupported(Method method) {
return String.format(CODE_UNSUPPORTED, method, type.getName());
}以
+Protocol
接口的destroy
方法为例,生成的内容如下:+ +
throw new UnsupportedOperationException("The method public abstract void org.apache.dubbo.rpc.Protocol.destroy() of interface org.apache.dubbo.rpc.Protocol is not adaptive method!");有 Adaptive 注解标注的方法
对于有 Adaptive 注解标注的方法,
++ +
// 查找 URL 类型的参数
int urlTypeIndex = getUrlTypeIndex(method);
if (urlTypeIndex != -1) {
// 生成 URL 判空检查和赋值的代码
code.append(generateUrlNullCheck(urlTypeIndex));
} else {
// 如果参数中没有直接出现 URL 类型,生成间接情况下的 URL 判空和赋值代码
code.append(generateUrlAssignmentIndirectly(method));
}
// 获取方法的 Adaptive 注解的 value
String[] value = getMethodAdaptiveValue(adaptiveAnnotation);
// 检测是否有 Invocation 类型的参数
boolean hasInvocation = hasInvocationArgument(method);
// 生成 Invocation 判空检查代码
code.append(generateInvocationArgumentNullCheck(method));
// 生成拓展名赋值代码
code.append(generateExtNameAssignment(value, hasInvocation));
// 生成拓展名判空检查代码
code.append(generateExtNameNullCheck(value));
// 生成获取拓展和赋值代码
code.append(generateExtensionAssignment());
// 生成调用和返回代码
code.append(generateReturnAndInvocation(method));查找 URL 类型的参数
直接从方法的参数类型列表中查找第一个
+URL
类型的参数,返回其索引。+ +
private int getUrlTypeIndex(Method method) {
int urlTypeIndex = -1;
// 遍历方法的参数类型列表
Class<?>[] pts = method.getParameterTypes();
for (int i = 0; i < pts.length; ++i) {
// 查找第一个 URL 类型的参数
if (pts[i].equals(URL.class)) {
urlTypeIndex = i;
break;
}
}
return urlTypeIndex;
}
// 生成 URL 参数判空检查和赋值代码
private String generateUrlNullCheck(int index) {
return String.format(CODE_URL_NULL_CHECK, index, URL.class.getName(), index);
}间接从方法的参数类型列表中,查找
+URL
类型的参数,并生成判空检查和赋值代码。+ +
private String generateUrlAssignmentIndirectly(Method method) {
Class<?>[] pts = method.getParameterTypes();
Map<String, Integer> getterReturnUrl = new HashMap<>();
// 遍历方法的参数类型列表
for (int i = 0; i < pts.length; ++i) {
// 遍历某一个参数类型的全部方法,查找可以返回 URL 类型的 “getter” 方法
for (Method m : pts[i].getMethods()) {
String name = m.getName();
// 1. 方法名以 get 开头,或者方法名大于 3 个字符
// 2. 方法的访问权限为 public
// 3. 非静态方法
// 4. 方法参数数量为 0
// 5. 方法返回值类型为 URL
if ((name.startsWith("get") || name.length() > 3)
&& Modifier.isPublic(m.getModifiers())
&& !Modifier.isStatic(m.getModifiers())
&& m.getParameterTypes().length == 0
&& m.getReturnType() == URL.class) {
// 保存方法名->索引的映射
getterReturnUrl.put(name, i);
}
}
}
if (getterReturnUrl.size() <= 0) {
// 如果没有找到 “getter” 方法,抛出异常
throw new IllegalStateException("Failed to create adaptive class for interface " + type.getName()
+ ": not found url parameter or url attribute in parameters of method " + method.getName());
}
// 优先选择方法名为 getUrl 的方法,如果没有则选第一个
Integer index = getterReturnUrl.get("getUrl");
if (index != null) {
return generateGetUrlNullCheck(index, pts[index], "getUrl");
} else {
Map.Entry<String, Integer> entry = getterReturnUrl.entrySet().iterator().next();
return generateGetUrlNullCheck(entry.getValue(), pts[entry.getValue()], entry.getKey());
}
}
// 生成 URL 参数判空检查和赋值代码
private String generateGetUrlNullCheck(int index, Class<?> type, String method) {
StringBuilder code = new StringBuilder();
code.append(String.format("if (arg%d == null) throw new IllegalArgumentException(\"%s argument == null\");\n",
index, type.getName()));
code.append(String.format("if (arg%d.%s() == null) throw new IllegalArgumentException(\"%s argument %s() == null\");\n",
index, method, type.getName(), method));
code.append(String.format("%s url = arg%d.%s();\n", URL.class.getName(), index, method));
return code.toString();
}以
+Protocol
的refer
和export
方法为例,生成的内容如下:+ +
// refer
if (arg1 == null) throw new IllegalArgumentException("url == null");
org.apache.dubbo.common.URL url = arg1;
// export
if (arg0 == null) throw new IllegalArgumentException("org.apache.dubbo.rpc.Invoker argument == null");
if (arg0.getUrl() == null) throw new IllegalArgumentException("org.apache.dubbo.rpc.Invoker argument getUrl() == null");
org.apache.dubbo.common.URL url = arg0.getUrl();获取 Adaptive 注解的 value
+ +
private String[] getMethodAdaptiveValue(Adaptive adaptiveAnnotation) {
String[] value = adaptiveAnnotation.value();
// 如果 value 为空,使用类名生成 value
// 效果:LoadBalance -> load.balance
if (value.length == 0) {
String splitName = StringUtils.camelToSplitName(type.getSimpleName(), ".");
value = new String[]{splitName};
}
return value;
}检测 Invocation 类型的参数
检测是否有
+Invocation
类型的参数,并生成判空检查代码和赋值代码。从Invocation
可以获得methodName
。+ +
private boolean hasInvocationArgument(Method method) {
Class<?>[] pts = method.getParameterTypes();
return Arrays.stream(pts).anyMatch(p -> CLASSNAME_INVOCATION.equals(p.getName()));
}
private String generateInvocationArgumentNullCheck(Method method) {
Class<?>[] pts = method.getParameterTypes();
return IntStream.range(0, pts.length).filter(i -> CLASSNAME_INVOCATION.equals(pts[i].getName()))
.mapToObj(i -> String.format(CODE_INVOCATION_ARGUMENT_NULL_CHECK, i, i))
.findFirst().orElse("");
}以
+LoadBalance
的select
方法为例,生成的内容如下:+ +
if (arg2 == null) throw new IllegalArgumentException("invocation == null");
String methodName = arg2.getMethodName();获取拓展名
本方法用于根据
+SPI
和Adaptive
注解的value
生成“获取拓展名”的代码,同时生成逻辑还受Invocation
影响,因此相对复杂。总结的规则如下:+
+- 正常情况下,使用 url.getParameter(value[i]) 获取
+- 如果默认拓展名非空,使用 url.getParameter(value[i], defaultExtName) 获取
+- 如果存在 Invocation,不论默认拓展名是否为空,总是使用 url.getMethodParameter(methodName, value[i], defaultExtName) 获取
+- 因为 protocol 是 url 的一部分,所以可以直接通过 getProtocol 获取。是否使用默认拓展名的方式就退化为原始的三元表达式。
++ +
private String generateExtNameAssignment(String[] value, boolean hasInvocation) {
// TODO: refactor it
String getNameCode = null;
// 逆序遍历 value(Adaptive 的 value)
for (int i = value.length - 1; i >= 0; --i) {
// 当 i 为最后一个元素的索引(因为是逆序遍历,第一轮就进入本分支)
if (i == value.length - 1) {
// 默认拓展名非空
if (null != defaultExtName) {
// protocol 是 url 的一部分,可以通过 getProtocol 方法获取,其他的则必须从 URL 参数中获取
if (!"protocol".equals(value[i])) {
if (hasInvocation) {
// 如果有 Invocation,则使用 url.getMethodParameter 获取
// url.getMethodParameter(methodName, value[i], defaultExtName)
getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
} else {
// url.getParameter(value[i], defaultExtName)
getNameCode = String.format("url.getParameter(\"%s\", \"%s\")", value[i], defaultExtName);
}
} else {
// ( url.getProtocol() == null ? defaultExtName : url.getProtocol() )
getNameCode = String.format("( url.getProtocol() == null ? \"%s\" : url.getProtocol() )", defaultExtName);
}
// 默认拓展名为空
} else {
if (!"protocol".equals(value[i])) {
if (hasInvocation) {
// url.getMethodParameter(methodName, value[i], defaultExtName)
getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
} else {
// url.getParameter(value[i])
getNameCode = String.format("url.getParameter(\"%s\")", value[i]);
}
} else {
// url.getProtocol()
getNameCode = "url.getProtocol()";
}
}
} else {
if (!"protocol".equals(value[i])) {
if (hasInvocation) {
// url.getMethodParameter(methodName, value[i], defaultExtName)
getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
} else {
// url.getParameter(value[i], getNameCode)
getNameCode = String.format("url.getParameter(\"%s\", %s)", value[i], getNameCode);
}
} else {
// url.getProtocol() == null ? "dubbo" : url.getProtocol()
getNameCode = String.format("url.getProtocol() == null ? (%s) : url.getProtocol()", getNameCode);
}
}
}
return String.format(CODE_EXT_NAME_ASSIGNMENT, getNameCode);
}加载拓展
+ +
private String generateExtensionAssignment() {
return String.format(CODE_EXTENSION_ASSIGNMENT, type.getName(), ExtensionLoader.class.getSimpleName(), type.getName());
}以
+Protocol
接口的refer
方法为例,生成的内容如下:+ +
org.apache.dubbo.rpc.Protocol extension = (org.apache.dubbo.rpc.Protocol)ExtensionLoader.getExtensionLoader(org.apache.dubbo.rpc.Protocol.class).getExtension(extName);调用与返回
生成方法调用语句,如有必要,返回结果。
++ +
private String generateReturnAndInvocation(Method method) {
String returnStatement = method.getReturnType().equals(void.class) ? "" : "return ";
String args = IntStream.range(0, method.getParameters().length)
.mapToObj(i -> String.format(CODE_EXTENSION_METHOD_INVOKE_ARGUMENT, i))
.collect(Collectors.joining(", "));
return returnStatement + String.format("extension.%s(%s);\n", method.getName(), args);
}以
+Protocol
接口的refer
方法为例,生成的内容如下:+ +
return extension.refer(arg0, arg1);++新版本通过提炼方法、使用流式编程和使用
+String.format()
代替 StringBuilder,提供了更好的代码可读性。官方写得源码解析真好。参考文章
+
+]]> +- SPI 自适应拓展
++ + +java +dubbo +spi ++ 使用 logrotate 滚动 Docker 容器内的 Nginx 的日志 +/2023/12/02/rotating-nginx-logs-in-docker-container-with-logrotate/ +Nginx
没有提供开箱即用的日志滚动功能,而是将其交给使用者自己实现。你既可以按照官方文档的建议通过编写脚本实现,也可以使用logrotate
管理日志。但是和在普通场景下不同,在使用Docker
运行Nginx
时,你可能需要额外考虑一点细节。本文记录了在为Docker
中的Nginx
的日志文件配置滚动功能过程中遇到的一些问题和思考。 + + +Nginx 滚动日志
官方文档
++In order to rotate log files, they need to be renamed first. After that USR1 signal should be sent to the master process. The master process will then re-open all currently open log files and assign them an unprivileged user under which the worker processes are running, as an owner. After successful re-opening, the master process closes all open files and sends the message to worker process to ask them to re-open files. Worker processes also open new files and close old files right away. As a result, old files are almost immediately available for post processing, such as compression.
+根据官方文档的解释,滚动日志文件的流程应如下,你可以自己编写
+Shell
脚本配合crontab
实现定时滚动功能。+
+- 首先重命名日志文件
+- 之后发送
+USR1
信号给Nginx
主进程,Nginx
将重新打开日志文件- 对日志文件进行后处理,比如压缩(可选)
++ +
mv access.log access.log.0
kill -USR1 `cat master.nginx.pid`
sleep 1
gzip access.log.0 # do something with access.log.0说明
+
+- 在没有执行
+kill
命令前,即便已经重命名了日志文件,Nginx
还是会向重命名后的文件写入日志。因为在Linux
系统中,系统内核是根据文件描述符定位文件的。- +
USR1
是自定义信号,软件的作者自己确定收到该信号后做什么。在Nginx
中,主进程收到信号后,会重新打开所有当前打开的日志文件并将它们分配给一个非特权用户作为所有者,工作进程就是在该所有者下运行的。成功重新打开后,主进程关闭所有打开的文件并向工作进程发送消息,要求它们重新打开文件。工作进程也打开新文件并立即关闭旧文件。使用 logrotate
++logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
++
logrotate
旨在简化生成大量日志文件的系统的管理。它允许自动滚动、压缩、删除和邮寄日志文件。每个日志文件可以在每天、每周、每月或当它变得太大时处理。Linux
一般默认安装了logrotate
。默认配置文件
查看默认配置文件:
+cat /etc/logrotate.conf
。+ +
# see "man logrotate" for details
# 每周滚动日志文件
weekly
# 默认使用 adm group,因为这是 /var/log/syslog 的所属组
su root adm
# 保留 4 周的备份(其实是保留 4 个备份,对应 weekly 的设置,就是保留 4 周)
rotate 4
# 在滚动旧日志文件后,创建新的空日志文件
create
# 使用日期作为滚动日志文件的后缀
#dateext
# 如果你希望压缩日志文件,请取消注释
#compress
# 软件包将日志滚动的配置信息放入此目录中
include /etc/logrotate.d
# system-specific logs may be also be configured here.配置信息所在目录
查看日志滚动的配置信息所在的目录:
+ls /etc/logrotate.d/
。+ +
alternatives apport apt bootlog btmp dpkg rsyslog ubuntu-advantage-tools ufw unattended-upgrades wtmp为 Nginx 新增配置
为
+Nginx
新增日志滚动配置,vim /etc/logrotate.d/nginx
。+ +
/path/to/your/nginx/logs/*.log {
# 切换用户
su moralok moralok
# 每天滚动日志文件
daily
# 使用日期作为滚动日志文件的后缀
dateext
# 如果日志丢失,不报错继续滚动下一个日志
missingok
# 保留 31 个备份
rotate 31
# 不压缩
nocompress
# 整个日志组运行一次的脚本
sharedscripts
# 滚动后的处理
postrotate
# 重新打开日志文件
docker exec nginx sh -c "[ ! -f /var/run/nginx.pid ] || (kill -USR1 `docker exec nginx cat /var/run/nginx.pid`; echo 'Successfully rotating nginx logs.')"
endscript
}验证配置和测试
测试配置文件是否有错误,
+logrotate -d /etc/logrotate.d/nginx
。强制滚动:
+logrotate -f /etc/logrotate.d/nginx
一些坑
/var/lib/logrotate/ 权限问题
当你使用校验过配置文件的正确性后,尝试强制滚动时,可能会遇到报错。
++ +
logrotate -f /etc/logrotate.d/nginx
error: error creating output file /var/lib/logrotate/status.tmp: Permission denied这是因为 logrotate 会在
+/var/lib/logrotate/
目录下创建status
文件。查看目录权限可知,需要以root
身份运行logrotate
。+ +
ll /var/lib/logrotate
total 12
drwxr-xr-x 2 root root 4096 Dec 2 08:35 ./
drwxr-xr-x 44 root root 4096 Jun 26 09:02 ../
-rw-r--r-- 1 root root 1395 Dec 2 08:35 status事实上,
+logrotate -d /etc/logrotate.d/nginx
命令也会读取/var/lib/logrotate/status
,但是other
对该目录也有r
读取权限,所以没有报错。+ +
logrotate -d /etc/logrotate.d/nginx
WARNING: logrotate in debug mode does nothing except printing debug messages! Consider using verbose mode (-v) instead if this is not what you want.
reading config file /etc/logrotate.d/nginx
Reading state from file: /var/lib/logrotate/status
...日志文件夹的权限
即使你使用
+root
身份运行logrotate
,你可能还会遇到以下报错+ +
logrotate -f /etc/logrotate.d/nginx
error: skipping "/path/to/your/nginx/logs/*.log" because parent directory has insecure permissions (It's world writable or writable by group which is not "root") Set "su" directive in config file to tell logrotate which user/group should be used for rotation.你需要在配置文件中,使用
+su <user> <group>
指定日志所在文件夹所属的用户和组,logrotate
才能正确读写。由宿主机还是容器主导
首先
+Nginx
的日志文件夹通过挂载映射到宿主机,日志滚动既可以由宿主机主导,也可以由容器主导,不过不论如何我们都需要向Docker
容器内的Nginx
发送USR1
信号。有人倾向于在容器内完成所有工作,和宿主机几乎完全隔离;我个人更青睐于由宿主机主导,因为容器内的环境并不总是拥有你想要使用的软件(除非你总是定制自己使用的镜像),甚至标准镜像往往非常精简。在
+logrotate
配置中的postrotate
部分添加脚本,使用docker exec
在容器内执行命令,完成向Nginx
发送信号的工作。脚本的处理逻辑大概是“如果存在/var/run/nginx.pid
,就执行kill -USR1 \`cat /var/run/nginx.pid\`
命令,并打印成功的消息”。但是我看到很多文章中分享的配置类似下面这样:+ +
docker exec nginx sh -c "if [ -f /var/run/nginx.pid ]; then kill -USR1 $(docker exec nginx cat /var/run/nginx.pid); fi"
docker exec nginx sh -c "[ ! -f /var/run/nginx.pid ] || kill -USR1 `cat /var/run/nginx.pid`;"经过测试都会有以下报错,我不清楚是否大多是抓取发布的文章,也不清楚他们是否测试过,对于
+Shell
脚本写得不多的我来说,半夜测试反复排查错误真是头昏脑胀。在我原先的理解里,-c
后面的脚本是整体发到容器内部执行的,后来我才意识到,我对脚本内部的命令在宿主机还是容器里执行的理解是错误的。+ +
cat: /var/run/nginx.pid: No such file or directory继续写入旧日志文件
在使用
+logrotate -f /etc/logrotate.d/nginx
测试通过后的第二天,发现虽然创建了新的日志文件,但是Nginx
继续写入到旧的日志文件。这不同于网上很多文章提到的“没有发送USR1
信号给Nginx
”的情况。尝试手动发送信号,观察效果。
++ +
docker exec nginx sh -c "kill -USR1 `docker exec nginx cat /var/run/nginx.pid`"发现虽然终止了继续写入到旧的文件,但是在宿主机读取日志时,提示没有权限。
++ +
cat access.log
cat: access.log: Permission denied查看日志文件权限,发现不对劲。新建的
+access.log
的权限为600
,所属用户从moralok
变为systemd-resolve
,失去了只有所属用户才拥有的rw
权限。++此时虽然注意到没有成功创建新的
+error.log
,但是只是以为对于空的日志文件不滚动。并且在后续重现问题时发现此时其实可以在容器里看到日志开始写入新的日志文件。+ +
ll
total 136
drwxrwxr-x 2 moralok moralok 4096 Dec 3 00:00 ./
drwxrwxr-x 4 moralok moralok 4096 Nov 30 17:25 ../
-rw------- 1 moralok moralok 0 Dec 3 00:00 access.log
-rw-r--r-- 1 systemd-resolve root 109200 Dec 3 07:01 access.log-20231203
-rw-r--r-- 1 systemd-resolve root 0 Dec 2 19:07 error.log这个时候我的想法是,既然成功创建了新的日志文件,肯定是
+Nginx
接收到了USR1
信号。怎么会出现“crontab
触发时发送信号有问题,手动发送却没问题”的情况呢?难道是两者触发的执行方式有所不同?还是说宿主机创建的文件会有问题?注意到新的日志文件所属的用户和组和原日志文件所属的用户和组不同,我开始怀疑创建文件的过程有问题。在反复测试尝试重现问题后,我把关注点放到了create
配置上。其实在最开始,我就关注了它,我想既然在默认的配置文件中已经设置而且我也不修改,那么就不在/etc/logrotate.d/nginx
添加了。我甚至花了很多时间浏览文档,确认它在缺省后面的权限属性时会使用原日志文件的权限属性。当时我还专门记录了一个疑问,“文档说在运行postrotate
脚本前创建新文件,可是在测试验证时,新文件是Nginx
接收USR1
信号后重新打开文件时创建的,在脚本执行报错或者脚本中并不发送信号时,不会产生新文件”。现在想来,都是坑,坑里注定要灌满眼泪!在
+/etc/logrotate.d/nginx
添加create
后,成功重现问题。+ +
...
renaming /path/to/your/nginx/logs/access.log to /path/to/your/nginx/logs/access.log-20231203
creating new /path/to/your/nginx/logs/access.log mode = 0644 uid = 101 gid = 0
error: error setting owner of /path/to/your/nginx/logs/access.log to uid 101 and gid 0: Operation not permitted
switching euid to 0 and egid to 0可见
+logrotate -f /etc/logrotate.d/nginx
,并没有使用到默认配置/etc/logrotate.conf
,而crontab
触发logrotate
时使用到了。修改为create 0644 moralok moralok
,成功解决问题。可以确认create
在缺省权限属性的时候,如果日志文件因为挂载到容器中而被修改了所属用户,logrotate
按照原文件的权限属性创建新文件时会报错,从而导致脚本不能正常执行,Nginx
不会收到信号,error.log
也不会继续滚动。按此原因推理,在配置文件中,加入nocreate
也可以解决问题,并且更加符合官方文档建议的流程。++枯坐一下午,百思不得其解,想到抓狂。不得不说真的很讨厌这类问题,特定条件下奇怪的问题,食之无味,弃之还不行!如果照着网上的文章,一开始就添加配置真的不会遇到啊。可是不喜欢不明不白地修改配置来解决问题,也不喜欢一次性加很多设置却不知道各个配置的功能,特别是在我的理解里这个默认配置似乎没问题的情况下。明明想要克制住不知重点还不断深入探索细节的坏习惯,却还是被一个
+Bug
带着花费了大量的时间和精力,解决了一个照着抄就不会遇到的问题。虽然真的有收获,真的解决了前一晚留下的疑问,可是不甘心啊,气气气!而且为什么这样会有问题,我还是不懂!logrotate 备忘
+命令参数
+ +
-d, --debug : 打开调试模式,这意味着不会对日志进行任何更改,并且 logrotate 状态文件不会更新。仅打印调试消息。
-f, --force : 告诉 logrotate 强制滚动,即使它认为这没有必要。有时,在将新条目添加到 logrotate 配置文件后,或者如果已手动删除旧日志文件,这会很有用,因为将创建新文件,并且正确地继续记录日志。
-m, --mail <command> : 告诉 logrotate 在邮寄日志时使用哪个命令。此命令应接受两个参数:消息的主题,收件人。然后,该命令必须读取标准输入上的消息并将其邮寄给收件人。默认邮件命令是 /bin/mail -s。
-s, --state <statefile> : 告诉 logrotate 使用备用状态文件。如果 logrotate 以不同用户身份运行不同的日志文件集,这会非常有用。默认状态文件是 /var/lib/logrotate/status。
--usage : 打印简短的用法信息。
--?, --help : 打印帮助信息。
-v, --verbose : 打开详细模式,例如在滚动期间显示消息。常用配置文件参数
+ +
+ + +参数 +说明 ++ +daily +周期:每天 ++ +weekly +周期:每周 ++ monthly 周期:每月 @@ -4340,153 +4480,36 @@ - -Dubbo SPI 的工作原理 -/2023/11/28/how-does-Dubbo-SPI-works/ -SPI
作为一种服务发现机制,允许程序在运行时动态地加载具体实现类。因其强大的可拓展性,SPI
被广泛应用于各类技术框架中,例如JDBC
驱动、Spring
和Dubbo
等等。Dubbo
并未使用原生的Java SPI
,而是重新实现了一套更加强大的Dubbo SPI
。本文将简单介绍SPI
的设计理念,通过示例带你体会SPI
的作用,通过Dubbo
获取拓展的流程图和源码分析带你理解Dubbo SPI
的工作原理。深入了解Dubbo SPI
,你将能更好地利用这一机制为你的程序提供灵活的拓展功能。 +Spring 中 @PropertySource 注解的使用和源码分析 +/2023/12/07/use-and-analysis-of-PropertySource-annotation-in-Spring/ +- @PropertySource
注解提供了一种方便的声明性机制,用于将PropertySource
添加到Spring
容器的Environment
环境中。该注解通常搭配@Configuration
注解一起使用。本文将介绍如何使用@PropertySource
注解,并通过分析源码解释外部配置文件是如何被解析进入Spring
的Environment
中。 -SPI 简介
- - -
SPI
的全称是Service Provider Interface
,是一种服务发现机制。一般情况下,一项服务的接口和具体实现,都是服务提供者编写的。在SPI
机制中,一项服务的接口是服务使用者编写的,不同的服务提供者编写不同的具体实现。在程序运行时,服务加载器动态地为接口加载具体实现类。因为SPI
具备“动态加载”的特性,我们很容易通过它为程序提供拓展功能。以Java
的JDBC
驱动为例,JDK 提供了java.sql.Driver
接口,各个数据库厂商,例如MySQL
、Oracle
提供具体的实现。目前 SPI 的实现方式大多是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类:
--
-- Java SPI:
-META-INF/services/full.qualified.interface.name
- Dubbo SPI:
-META-INF/dubbo/full.qualified.interface.name
(还有其他目录可供选择)- Spring SPI:
-META-INF/spring.factories
SPI 示例
Java SPI 示例
定义一个接口
-Animal
。- -
public interface Animal {
void bark();
}定义两个实现类
-Dog
和Cat
。- -
public class Dog implements Animal {
public void bark() {
System.out.println("Dog bark...");
}
}
public class Cat implements Animal {
public void bark() {
System.out.println("Cat bark...");
}
}在
-META-INF/services
文件夹下创建一个文件,名称为Animal
的全限定名com.moralok.dubbo.spi.test.Animal
,文件内容为实现类的全限定名,实现类的全限定名之间用换行符分隔。- -
com.moralok.dubbo.spi.test.Dog
com.moralok.dubbo.spi.test.Cat进行测试。
-- -
public class JavaSPITest {
void bark() {
System.out.println("Java SPI");
System.out.println("============");
ServiceLoader<Animal> serviceLoader = ServiceLoader.load(Animal.class);
serviceLoader.forEach(Animal::bark);
}
}测试结果
-+
Java SPI
============
Dog bark...
Cat bark...使用方式
+
@Configuration
注解表示这是一个配置类,Spring
在处理配置类时,会解析并处理配置类上的@PropertySource
注解,将对应的配置文件解析为PropertySource
,添加到Spring
容器的Environment
环境中。这样就可以在其他的Bean
中,使用@Value
注解使用这些配置-
public class PropertySourceConfig {
public Player player() {
return new Player();
}
}
public class Player {
private String name;
private Integer age;
private String nickname;
// 省略 setter 和 getter 方法
}Dubbo SPI 示例
-
Dubbo
并未使用原生的Java SPI
,而是重新实现了一套功能更加强大的SPI
机制。Dubbo SPI
的配置文件放在META-INF/dubbo
文件夹下,名称仍然是接口的全限定名,但是内容是“名称->实现类的全限定名”的键值对,另外接口需要标注SPI
注解。+
dog = com.moralok.dubbo.spi.test.Dog
cat = com.moralok.dubbo.spi.test.Cat配置文件
+-
player.nickname=Tom进行测试。
-+
public class DubboSPITest {
void bark() {
System.out.println("Dubbo SPI");
System.out.println("============");
ExtensionLoader<Animal> extensionLoader = ExtensionLoader.getExtensionLoader(Animal.class);
Animal dog = extensionLoader.getExtension("dog");
dog.bark();
Animal cat = extensionLoader.getExtension("cat");
cat.bark();
}
}测试类
+
public class PropertySourceTest {
public void test() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PropertySourceConfig.class);
Player player = (Player) ac.getBean("player");
System.out.println(player);
ConfigurableEnvironment environment = ac.getEnvironment();
String property = environment.getProperty("player.nickname");
System.out.println(property);
ac.close();
}
}测试结果
-+
Dubbo SPI
============
Dog bark...
Cat bark...-
Player{name='null', age=null, nickname='Tom'}
TomDubbo 获取扩展流程图
+源码分析
关于
+ +Spring
是如何处理配置类的请参见之前的文章:获取 @PropertySource 注解属性
+
Spring
在解析配置类构建配置模型时,会对配置类上的@PropertySource
注解进行处理。Spring
将获取所有的@PropertySource
注解属性,并遍历进行处理。+
+- +
@PropertySource
注解是可重复的,一个类上可以标注多个- +
@PropertySources
注解包含@PropertySource
注解-
protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass)
throws IOException {
// ...
// 处理 @PropertySource 注解
// 获取所有 @PropertySource 注解的属性并遍历。注意该注解为可重复的。
for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), PropertySources.class,
org.springframework.context.annotation.PropertySource.class)) {
// 如果 environment 是 ConfigurableEnvironment 的一个实例,目前恒为 true
if (this.environment instanceof ConfigurableEnvironment) {
// 处理单个 @PropertySource 注解的属性
processPropertySource(propertySource);
}
else {
logger.warn("Ignoring @PropertySource annotation on [" + sourceClass.getMetadata().getClassName() +
"]. Reason: Environment must implement ConfigurableEnvironment");
}
}
// ...
}Dubbo SPI 源码分析
获取 ExtensionLoader
- -
private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<>(64);
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
if (type == null) {
throw new IllegalArgumentException("Extension type == null");
}
if (!type.isInterface()) {
throw new IllegalArgumentException("Extension type (" + type + ") is not an interface!");
}
if (!withExtensionAnnotation(type)) {
throw new IllegalArgumentException("Extension type (" + type +
") is not an extension, because it is NOT annotated with @" + SPI.class.getSimpleName() + "!");
}
// 从缓存中获取,如果缓存未命中,则创建,保存到缓存并返回
ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
if (loader == null) {
EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
}
return loader;
}这个方法包含了如下步骤:
--
-- 参数校验。
-- 从缓存
-EXTENSION_LOADERS
中获取与拓展类对应的ExtensionLoader
,如果缓存未命中,则创建一个新的实例,保存到缓存并返回。--“从缓存中获取,如果缓存未命中,则创建,保存到缓存并返回”,类似的
-getOrCreate
的处理模式在Dubbo
的源码中经常出现。-
EXTENSION_LOADERS
是ExtensionLoader
的静态变量,保存了“拓展类->ExtensionLoader
”的映射关系。根据 name 获取 Extension
- -
public T getExtension(String name) {
return getExtension(name, true);
}
public T getExtension(String name, boolean wrap) {
if (StringUtils.isEmpty(name)) {
throw new IllegalArgumentException("Extension name == null");
}
if ("true".equals(name)) {
// 获取默认的拓展实现
return getDefaultExtension();
}
// Holder,用于持有目标对象
final Holder<Object> holder = getOrCreateHolder(name);
// 双重检查
Object instance = holder.get();
if (instance == null) {
synchronized (holder) {
instance = holder.get();
if (instance == null) {
// 创建拓展实例,设置到 holder 中。
instance = createExtension(name, wrap);
holder.set(instance);
}
}
}
return (T) instance;
}
private final ConcurrentMap<String, Holder<Object>> cachedInstances = new ConcurrentHashMap<>();
private Holder<Object> getOrCreateHolder(String name) {
Holder<Object> holder = cachedInstances.get(name);
if (holder == null) {
cachedInstances.putIfAbsent(name, new Holder<>());
holder = cachedInstances.get(name);
}
return holder;
}这个方法中获取
-Holder
和获取拓展实例都是使用getOrCreate
的模式。-
Holder
用于持有拓展实例。cachedInstances
是ExtensionLoader
的成员变量,保存了“name->Holder
(拓展实例)”的映射关系。创建 Extension
- -
private T createExtension(String name, boolean wrap) {
// 从配置文件中加载所有的拓展类,可得到“name->拓展实现类”的映射关系表
Class<?> clazz = getExtensionClasses().get(name);
if (clazz == null || unacceptableExceptions.contains(name)) {
throw findException(name);
}
try {
// 使用 getOrCreate 模式获取拓展实例
T instance = (T) EXTENSION_INSTANCES.get(clazz);
if (instance == null) {
EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.getDeclaredConstructor().newInstance());
instance = (T) EXTENSION_INSTANCES.get(clazz);
}
// 向拓展实例中注入依赖
injectExtension(instance);
// 是否包装默认为 true
if (wrap) {
List<Class<?>> wrapperClassesList = new ArrayList<>();
if (cachedWrapperClasses != null) {
wrapperClassesList.addAll(cachedWrapperClasses);
wrapperClassesList.sort(WrapperComparator.COMPARATOR);
Collections.reverse(wrapperClassesList);
}
if (CollectionUtils.isNotEmpty(wrapperClassesList)) {
// 遍历包装类
for (Class<?> wrapperClass : wrapperClassesList) {
Wrapper wrapper = wrapperClass.getAnnotation(Wrapper.class);
// 区别于旧版本:支持使用 Wrapper 注解进行匹配
if (wrapper == null
|| (ArrayUtils.contains(wrapper.matches(), name) && !ArrayUtils.contains(wrapper.mismatches(), name))) {
// 如果有匹配的包装类,包装拓展实例
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
}
}
}
// 初始化拓展实例
initExtension(instance);
return instance;
} catch (Throwable t) {
throw new IllegalStateException("Extension instance (name: " + name + ", class: " +
type + ") couldn't be instantiated: " + t.getMessage(), t);
}
}这个方法包含如下步骤:
--
-- 通过
-getExtensionClasses
获取所有拓展类- 通过反射创建拓展实例
-- 向拓展实例中注入依赖
-- 将拓展实例包装在适配的
-Wrapper
对象中- 初始化拓展实例
-第一步是加载拓展类的关键,第三步和第四步是
-Dubbo IOC
和AOP
的具体实现。最后拓展实例的结构如下图。
- - -加载 Extension Class
- -
private Map<String, Class<?>> getExtensionClasses() {
// 使用 getOrCreate 模式获取所有拓展类
Map<String, Class<?>> classes = cachedClasses.get();
if (classes == null) {
synchronized (cachedClasses) {
classes = cachedClasses.get();
if (classes == null) {
classes = loadExtensionClasses();
cachedClasses.set(classes);
}
}
}
return classes;
}
private Map<String, Class<?>> loadExtensionClasses() {
// 1. 缓存默认拓展名
cacheDefaultExtensionName();
Map<String, Class<?>> extensionClasses = new HashMap<>();
// 遍历加载策略,加载各个策略的目录下的配置文件,获取拓展类
for (LoadingStrategy strategy : strategies) {
loadDirectory(extensionClasses, strategy.directory(), type.getName(), strategy.preferExtensionClassLoader(),
strategy.overridden(), strategy.excludedPackages());
loadDirectory(extensionClasses, strategy.directory(), type.getName().replace("org.apache", "com.alibaba"),
strategy.preferExtensionClassLoader(), strategy.overridden(), strategy.excludedPackages());
}
return extensionClasses;
}
// 如果存在默认拓展名,提取并缓存
private void cacheDefaultExtensionName() {
// 获取 SPI 注解
final SPI defaultAnnotation = type.getAnnotation(SPI.class);
if (defaultAnnotation == null) {
return;
}
String value = defaultAnnotation.value();
if ((value = value.trim()).length() > 0) {
// 对 SPI 的 value 内容进行切分
String[] names = NAME_SEPARATOR.split(value);
// 检测 name 是否合法
if (names.length > 1) {
throw new IllegalStateException("More than 1 default extension name on extension " + type.getName()
+ ": " + Arrays.toString(names));
}
if (names.length == 1) {
// 缓存默认拓展名,用于 getDefaultExtension
cachedDefaultName = names[0];
}
}
}依次处理特定目录
代码参考旧版本更容易理解。处理过程在本质上就是依次加载
-META-INF/dubbo/internal/
、META-INF/dubbo/
、META-INF/services/
三个目录下的配置文件,获取拓展类。- -
loadDirectory(extensionClasses, DUBBO_INTERNAL_DIRECTORY);
loadDirectory(extensionClasses, DUBBO_DIRECTORY);
loadDirectory(extensionClasses, SERVICES_DIRECTORY);新版本使用原生的
-Java SPI
加载LoadingStrategy
,允许用户自定义加载策略。-
-- -
DubboInternalLoadingStrategy
,目录META-INF/dubbo/internal/
,优先级最高- -
DubboLoadingStrategy
,目录META-INF/dubbo/
,优先级普通- -
ServicesLoadingStrategy
,目录META-INF/services/
,优先级最低- -
private static volatile LoadingStrategy[] strategies = loadLoadingStrategies();
private static LoadingStrategy[] loadLoadingStrategies() {
// 通过 Java SPI 加载 LoadingStrategy
return stream(load(LoadingStrategy.class).spliterator(), false)
.sorted()
.toArray(LoadingStrategy[]::new);
}- - -
LoadingStrategy
的Java SPI
配置文件loadDirectory 方法
-
loadDirectory
方法先通过classLoader
获取所有的资源链接,然后再通过loadResource
方法加载资源。新版本中
-extensionLoaderClassLoaderFirst
可以设置是否优先使用ExtensionLoader's ClassLoader
获取资源链接。- -
private void loadDirectory(Map<String, Class<?>> extensionClasses, String dir, String type,
boolean extensionLoaderClassLoaderFirst, boolean overridden, String... excludedPackages) {
// filename = 文件夹路径 + type 的全限定名
String fileName = dir + type;
try {
Enumeration<java.net.URL> urls = null;
ClassLoader classLoader = findClassLoader();
// 区别于旧版本:先从 ExtensionLoader's ClassLoader 获取资源链接,默认为 false
if (extensionLoaderClassLoaderFirst) {
ClassLoader extensionLoaderClassLoader = ExtensionLoader.class.getClassLoader();
if (ClassLoader.getSystemClassLoader() != extensionLoaderClassLoader) {
urls = extensionLoaderClassLoader.getResources(fileName);
}
}
// 根据文件名加载所有同名文件
if (urls == null || !urls.hasMoreElements()) {
if (classLoader != null) {
urls = classLoader.getResources(fileName);
} else {
urls = ClassLoader.getSystemResources(fileName);
}
}
if (urls != null) {
while (urls.hasMoreElements()) {
java.net.URL resourceURL = urls.nextElement();
// 加载资源
loadResource(extensionClasses, classLoader, resourceURL, overridden, excludedPackages);
}
}
} catch (Throwable t) {
logger.error("Exception occurred when loading extension class (interface: " +
type + ", description file: " + fileName + ").", t);
}
}loadResource 方法
-
loadResource
方法用于读取和解析配置文件,并通过反射加载类,最后调用loadClass
方法进行其他操作。loadClass
方法用于操作缓存。新版本中
-excludedPackages
可以设置将指定包内的类都排除。- -
private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader,
java.net.URL resourceURL, boolean overridden, String... excludedPackages) {
try {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
String line;
String clazz = null;
// 按行读取配置内容
while ((line = reader.readLine()) != null) {
// 定位 # 字符,截取 # 字符之前的内容,# 字符之后的内容为注释,需要忽略
final int ci = line.indexOf('#');
if (ci >= 0) {
line = line.substring(0, ci);
}
line = line.trim();
if (line.length() > 0) {
try {
String name = null;
// 定位 = 字符,以 = 字符为界,截取键值对
int i = line.indexOf('=');
if (i > 0) {
name = line.substring(0, i).trim();
clazz = line.substring(i + 1).trim();
} else {
clazz = line;
}
if (StringUtils.isNotEmpty(clazz) && !isExcluded(clazz, excludedPackages)) {
// 加载类,并通过 loadClass 进行缓存
// 区别于旧版本:根据 excludedPackages 判断是否排除
loadClass(extensionClasses, resourceURL, Class.forName(clazz, true, classLoader), name, overridden);
}
} catch (Throwable t) {
IllegalStateException e = new IllegalStateException(
"Failed to load extension class (interface: " + type + ", class line: " + line + ") in " + resourceURL +
", cause: " + t.getMessage(), t);
exceptions.put(line, e);
}
}
}
}
} catch (Throwable t) {
logger.error("Exception occurred when loading extension class (interface: " +
type + ", class file: " + resourceURL + ") in " + resourceURL, t);
}
}loadClass 方法
-
loadClass
方法设置了多个缓存,比如cachedAdaptiveClass
、cachedWrapperClasses
、cachedNames
和cachedClasses
。新版本中
-overridden
可以设置是否覆盖cachedAdaptiveClass
、cachedClasses
的name->clazz
。- -
private void loadClass(Map<String, Class<?>> extensionClasses, java.net.URL resourceURL, Class<?> clazz, String name,
boolean overridden) throws NoSuchMethodException {
// 检测 clazz 是否合法
if (!type.isAssignableFrom(clazz)) {
throw new IllegalStateException("Error occurred when loading extension class (interface: " +
type + ", class line: " + clazz.getName() + "), class "
+ clazz.getName() + " is not subtype of interface.");
}
if (clazz.isAnnotationPresent(Adaptive.class)) {
// 检测 clazz 是否有 Adaptive 注解,有则设置 cachedAdaptiveClass 缓存
// 区别于旧版本:根据 overriden 判断是否可覆盖
cacheAdaptiveClass(clazz, overridden);
} else if (isWrapperClass(clazz)) {
// 检测 clazz 是否是 Wrapper 类型,是则添加到 cachedWrapperClasses 缓存
cacheWrapperClass(clazz);
} else {
// 检测 clazz 是否有默认的构造器方法,如果没有,则抛出异常
clazz.getConstructor();
if (StringUtils.isEmpty(name)) {
// 如果 name 为空,则尝试从 Extension 注解中获取 name,或者使用小写的类名(可能截取 type 后缀)作为 name
name = findAnnotationName(clazz);
if (name.length() == 0) {
throw new IllegalStateException(
"No such extension name for the class " + clazz.getName() + " in the config " + resourceURL);
}
}
// 切分 name
String[] names = NAME_SEPARATOR.split(name);
if (ArrayUtils.isNotEmpty(names)) {
// 如果 clazz 有 Activate 注解,则缓存 names[0]->Activate 的映射关系
cacheActivateClass(clazz, names[0]);
for (String n : names) {
// 缓存 clazz->n 的映射关系
cacheName(clazz, n);
// 缓存 n->clazz 的映射关系,传递到最后,终于轮到 extensionClasses
// 区别于旧版本:根据 overriden 判断是否可覆盖
saveInExtensionClass(extensionClasses, clazz, n, overridden);
}
}
}
}
private void cacheAdaptiveClass(Class<?> clazz, boolean overridden) {
if (cachedAdaptiveClass == null || overridden) {
// 可以设置是否覆盖
cachedAdaptiveClass = clazz;
} else if (!cachedAdaptiveClass.equals(clazz)) {
throw new IllegalStateException("More than 1 adaptive class found: "
+ cachedAdaptiveClass.getName()
+ ", " + clazz.getName());
}
}
private void cacheWrapperClass(Class<?> clazz) {
if (cachedWrapperClasses == null) {
cachedWrapperClasses = new ConcurrentHashSet<>();
}
cachedWrapperClasses.add(clazz);
}
private void cacheActivateClass(Class<?> clazz, String name) {
Activate activate = clazz.getAnnotation(Activate.class);
if (activate != null) {
cachedActivates.put(name, activate);
} else {
// support com.alibaba.dubbo.common.extension.Activate
com.alibaba.dubbo.common.extension.Activate oldActivate =
clazz.getAnnotation(com.alibaba.dubbo.common.extension.Activate.class);
if (oldActivate != null) {
cachedActivates.put(name, oldActivate);
}
}
}Dubbo IOC
-
Dubbo IOC
是通过setter
方法注入依赖。Dubbo
首先通过反射获取目标类的所有方法,然后遍历方法列表,检测方法名是否具有setter
方法特征并满足条件,若有,则通过objectFactory
获取依赖对象,最后通过反射调用setter
方法将依赖设置到目标对象中。--与
-Spring IOC
相比,Dubbo IOC
实现的依赖注入功能更加简单,代码也更加容易理解。- -
private T injectExtension(T instance) {
// 检测是否有 objectFactory
if (objectFactory == null) {
return instance;
}
try {
// 遍历目标类的所有方法
// 区别于旧版本:增加了对 DisbaleInject、Inject 注解的处理
for (Method method : instance.getClass().getMethods()) {
// 检测是否是 setter 方法
if (!isSetter(method)) {
continue;
}
// 检测是否标注 DisableInject 注解
if (method.getAnnotation(DisableInject.class) != null) {
continue;
}
// 检测参数类型是否是原始类型
Class<?> pt = method.getParameterTypes()[0];
if (ReflectUtils.isPrimitives(pt)) {
continue;
}
// 获取属性名
String property = getSetterProperty(method);
// 检测是否标注 Inject 注解
Inject inject = method.getAnnotation(Inject.class);
if (inject == null) {
injectValue(instance, method, pt, property);
} else {
// 检测 Inject 是否启动、是否按照类型注入
if (!inject.enable()) {
continue;
}
if (inject.type() == Inject.InjectType.ByType) {
injectValue(instance, method, pt, null);
} else {
injectValue(instance, method, pt, property);
}
}
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return instance;
}
// 获取属性名
private String getSetterProperty(Method method) {
return method.getName().length() > 3 ? method.getName().substring(3, 4).toLowerCase() + method.getName().substring(4) : "";
}
// public、set 开头、只有一个参数
private boolean isSetter(Method method) {
return method.getName().startsWith("set")
&& method.getParameterTypes().length == 1
&& Modifier.isPublic(method.getModifiers());
}
private void injectValue(T instance, Method method, Class<?> pt, String property) {
try {
// 从 ObjectFactory 中获取依赖对象
Object object = objectFactory.getExtension(pt, property);
if (object != null) {
// 通过反射调用 setter 方法设置依赖
method.invoke(instance, object);
}
} catch (Exception e) {
logger.error("Failed to inject via method " + method.getName()
+ " of interface " + type.getName() + ": " + e.getMessage(), e);
}
}-
objectFactory
是ExtensionFactory
的自适应拓展,通过它获取依赖对象,本质上是根据目标拓展类获取ExtensionLoader
,然后获取其自适应拓展,过程代码如下。具体我们不再深入分析,可以参考Dubbo SPI 自适应拓展的工作原理。- -
public <T> T getExtension(Class<T> type, String name) {
if (type.isInterface() && type.isAnnotationPresent(SPI.class)) {
ExtensionLoader<T> loader = ExtensionLoader.getExtensionLoader(type);
if (!loader.getSupportedExtensions().isEmpty()) {
return loader.getAdaptiveExtension();
}
}
return null;
}参考文章
-
-]]>- Dubbo SPI
-- -java -dubbo -spi -- +Spring 中 @PropertySource 注解的使用和源码分析 -/2023/12/07/use-and-analysis-of-PropertySource-annotation-in-Spring/ -@PropertySource
注解提供了一种方便的声明性机制,用于将PropertySource
添加到Spring
容器的Environment
环境中。该注解通常搭配@Configuration
注解一起使用。本文将介绍如何使用@PropertySource
注解,并通过分析源码解释外部配置文件是如何被解析进入Spring
的Environment
中。 - - -使用方式
-
@Configuration
注解表示这是一个配置类,Spring
在处理配置类时,会解析并处理配置类上的@PropertySource
注解,将对应的配置文件解析为PropertySource
,添加到Spring
容器的Environment
环境中。这样就可以在其他的Bean
中,使用@Value
注解使用这些配置- -
public class PropertySourceConfig {
public Player player() {
return new Player();
}
}
public class Player {
private String name;
private Integer age;
private String nickname;
// 省略 setter 和 getter 方法
}配置文件
-- -
player.nickname=Tom测试类
-- -
public class PropertySourceTest {
public void test() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PropertySourceConfig.class);
Player player = (Player) ac.getBean("player");
System.out.println(player);
ConfigurableEnvironment environment = ac.getEnvironment();
String property = environment.getProperty("player.nickname");
System.out.println(property);
ac.close();
}
}测试结果
-- -
Player{name='null', age=null, nickname='Tom'}
Tom源码分析
关于
- -Spring
是如何处理配置类的请参见之前的文章:获取 @PropertySource 注解属性
-
Spring
在解析配置类构建配置模型时,会对配置类上的@PropertySource
注解进行处理。Spring
将获取所有的@PropertySource
注解属性,并遍历进行处理。-
-- -
@PropertySource
注解是可重复的,一个类上可以标注多个- -
@PropertySources
注解包含@PropertySource
注解- -
protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass)
throws IOException {
// ...
// 处理 @PropertySource 注解
// 获取所有 @PropertySource 注解的属性并遍历。注意该注解为可重复的。
for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), PropertySources.class,
org.springframework.context.annotation.PropertySource.class)) {
// 如果 environment 是 ConfigurableEnvironment 的一个实例,目前恒为 true
if (this.environment instanceof ConfigurableEnvironment) {
// 处理单个 @PropertySource 注解的属性
processPropertySource(propertySource);
}
else {
logger.warn("Ignoring @PropertySource annotation on [" + sourceClass.getMetadata().getClassName() +
"]. Reason: Environment must implement ConfigurableEnvironment");
}
}
// ...
}使用
- +IDEA
查看AnnotationAttributes
:使用
+IDEA
查看AnnotationAttributes
:处理 @PropertySource 注解属性
- 读取
@@ -4525,6 +4548,65 @@@PropertySource
注解属性的信息,如名称、编码和位置等等spring + ConfigurationProperties 一定要搭配 EnableConfigurationProperties 使用吗 +/2023/12/10/is-it-necessary-to-use-ConfigurationProperties-with-EnableConfigurationProperties/ ++ @ConfigurationProperties
和@EnableConfigurationProperties
是Spring Boot
中常用的注解,提供了方便和强大的外部化配置支持。尽管它们常常一起出现,但是它们真的必须一起使用吗?Spring Boot
的灵活性常常让我们忽略配置背后产生的作用究竟是什么?本文将从源码角度出发分析两个注解的作用时机和工作原理。 + + ++
+- 本文的写作动机继承自Spring 中 @PropertySource 注解的使用和源码分析,两者有点相似并且常被一起提及,都通过外部配置管理运行时的属性值,但实际的工作原理却并不相同。
+- 本文没有介绍它们的使用方式,如有需要可以参考 Guide to @ConfigurationProperties in Spring Boot。
+- 理解
+@Import
的工作原理对阅读本文的源码有非常大的帮助,可以参考Spring 中 @Import 注解的使用和源码分析。注解
+
ConfigurationProperties
是用于外部化配置的注解。如果你想绑定和验证某些外部属性(例如来自.properties
文件),就将其添加到类定义或@Configuration
类中的@Bean
方法。请注意,和@Value
相反,SpEL
表达式不会被求值,因为属性值是外部化的。查看ConfigurationProperties
注解的源码可知,该注解主要起到标记和存储一些信息的作用。+ +
public ConfigurationProperties {
// 可有效绑定到此对象的属性的名称前缀
String value() default "";
// 可有效绑定到此对象的属性的名称前缀
String prefix() default "";
// 绑定到此对象时是否忽略无效字段
boolean ignoreInvalidFields() default false;
// 绑定到此对象时是否忽略未知字段
boolean ignoreUnknownFields() default true;
}查看
+EnableConfigurationProperties
的源码,我们注意到它通过@Import
导入了EnableConfigurationPropertiesImportSelector
。+ +
public EnableConfigurationProperties {
// 使用 Spring 快速注册标注了 @ConfigurationProperties 的 bean。无论 value 如何,标准的 Spring Bean 也将被扫描。
Class<?>[] value() default {};
}注解的作用
查看
+EnableConfigurationPropertiesImportSelector
的源码,关注selectImports
方法。该方法返回了ConfigurationPropertiesBeanRegistrar
和ConfigurationPropertiesBindingPostProcessorRegistrar
的全限定类名,Spring
将注册它们。+ +
class EnableConfigurationPropertiesImportSelector implements ImportSelector {
private static final String[] IMPORTS = {
ConfigurationPropertiesBeanRegistrar.class.getName(),
ConfigurationPropertiesBindingPostProcessorRegistrar.class.getName() };
public String[] selectImports(AnnotationMetadata metadata) {
return IMPORTS;
}
}注册目标类
+
ConfigurationPropertiesBeanRegistrar
是一个内部类,查看ConfigurationPropertiesBeanRegistrar
的源码,关注registerBeanDefinitions
方法。注册的目标来自于:+
+- +
@EnableConfigurationProperties
的value
所指定的类中- 且标注了
+@ConfigurationProperties
的类+ +
public static class ConfigurationPropertiesBeanRegistrar
implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
// 1. 获取要注册的 Class
// 2. 遍历获取的结果进行注册
getTypes(metadata).forEach((type) -> register(registry,
(ConfigurableListableBeanFactory) registry, type));
}
private List<Class<?>> getTypes(AnnotationMetadata metadata) {
// 获取所有 @EnableConfigurationProperties 的属性
MultiValueMap<String, Object> attributes = metadata
.getAllAnnotationAttributes(
EnableConfigurationProperties.class.getName(), false);
// 获取属性 value 的值,处理后返回
return collectClasses(attributes == null ? Collections.emptyList()
: attributes.get("value"));
}
// 收集 Class
private List<Class<?>> collectClasses(List<?> values) {
return values.stream().flatMap((value) -> Arrays.stream((Object[]) value))
.map((o) -> (Class<?>) o).filter((type) -> void.class != type)
.collect(Collectors.toList());
}
// 注册 Bean 定义
private void register(BeanDefinitionRegistry registry,
ConfigurableListableBeanFactory beanFactory, Class<?> type) {
String name = getName(type);
// 检测是否包含 Bean 定义
if (!containsBeanDefinition(beanFactory, name)) {
// 如果没找到,则注册
registerBeanDefinition(registry, name, type);
}
}
// beanName = prefix + 全限定类名 or 全限定类名
private String getName(Class<?> type) {
ConfigurationProperties annotation = AnnotationUtils.findAnnotation(type,
ConfigurationProperties.class);
String prefix = (annotation != null ? annotation.prefix() : "");
return (StringUtils.hasText(prefix) ? prefix + "-" + type.getName()
: type.getName());
}
// 检测是否包含 Bean 定义
private boolean containsBeanDefinition(
ConfigurableListableBeanFactory beanFactory, String name) {
// 先检测当前工厂中是否包含
if (beanFactory.containsBeanDefinition(name)) {
return true;
}
// 如果没找到则检测父工厂是否包含
BeanFactory parent = beanFactory.getParentBeanFactory();
if (parent instanceof ConfigurableListableBeanFactory) {
return containsBeanDefinition((ConfigurableListableBeanFactory) parent,
name);
}
return false;
}
// 注册 Bean 定义
private void registerBeanDefinition(BeanDefinitionRegistry registry, String name,
Class<?> type) {
// 断言目标类标注了 @ConfigurationProperties
assertHasAnnotation(type);
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(type);
// registry 注册 Bean 定义
registry.registerBeanDefinition(name, definition);
}
// 断言目标类标注了 @ConfigurationProperties
private void assertHasAnnotation(Class<?> type) {
Assert.notNull(
AnnotationUtils.findAnnotation(type, ConfigurationProperties.class),
"No " + ConfigurationProperties.class.getSimpleName()
+ " annotation found on '" + type.getName() + "'.");
}
}注册后处理器
查看
+ConfigurationPropertiesBindingPostProcessorRegistrar
的源码,关注registerBeanDefinitions
方法。该方法注册了ConfigurationPropertiesBindingPostProcessor
和ConfigurationBeanFactoryMetadata
。+
+- 前者顾名思义,用于处理
+ConfigurationProperties
的绑定- 后者是用于在
+Bean
工厂初始化期间记住@Bean
定义元数据的实用程序类+ +
public class ConfigurationPropertiesBindingPostProcessorRegistrar
implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
if (!registry.containsBeanDefinition(
ConfigurationPropertiesBindingPostProcessor.BEAN_NAME)) {
registerConfigurationPropertiesBindingPostProcessor(registry);
registerConfigurationBeanFactoryMetadata(registry);
}
}
private void registerConfigurationPropertiesBindingPostProcessor(
BeanDefinitionRegistry registry) {
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(ConfigurationPropertiesBindingPostProcessor.class);
// Bean 定义的角色为基础设施
definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
registry.registerBeanDefinition(
ConfigurationPropertiesBindingPostProcessor.BEAN_NAME, definition);
}
private void registerConfigurationBeanFactoryMetadata(
BeanDefinitionRegistry registry) {
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(ConfigurationBeanFactoryMetadata.class);
definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
registry.registerBeanDefinition(ConfigurationBeanFactoryMetadata.BEAN_NAME,
definition);
}
}绑定
+
ConfigurationPropertiesBindingPostProcessor
是用于ConfigurationProperties
绑定的后处理器,关注afterPropertiesSet
方法还有核心方法postProcessBeforeInitialization
。+
+- 在
+afterPropertiesSet
方法中,它获取到了和自己一起注册的ConfigurationBeanFactoryMetadata
。- 在
+postProcessBeforeInitialization
方法中,先获取@ConfigurationProperties
,再进行绑定。+ +
public class ConfigurationPropertiesBindingPostProcessor implements BeanPostProcessor,
PriorityOrdered, ApplicationContextAware, InitializingBean {
public static final String BEAN_NAME = ConfigurationPropertiesBindingPostProcessor.class
.getName();
public static final String VALIDATOR_BEAN_NAME = "configurationPropertiesValidator";
private ConfigurationBeanFactoryMetadata beanFactoryMetadata;
private ApplicationContext applicationContext;
private ConfigurationPropertiesBinder configurationPropertiesBinder;
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
this.applicationContext = applicationContext;
}
public void afterPropertiesSet() throws Exception {
// We can't use constructor injection of the application context because
// it causes eager factory bean initialization
// 注入了 ConfigurationBeanFactoryMetadata(不理解注解描述的情况?)
this.beanFactoryMetadata = this.applicationContext.getBean(
ConfigurationBeanFactoryMetadata.BEAN_NAME,
ConfigurationBeanFactoryMetadata.class);
this.configurationPropertiesBinder = new ConfigurationPropertiesBinder(
this.applicationContext, VALIDATOR_BEAN_NAME);
}
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 1;
}
public Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException {
// 获取 @ConfigurationProperties
ConfigurationProperties annotation = getAnnotation(bean, beanName,
ConfigurationProperties.class);
if (annotation != null) {
// 绑定
bind(bean, beanName, annotation);
}
return bean;
}
private void bind(Object bean, String beanName, ConfigurationProperties annotation) {
// 获取 Bean 的类型
ResolvableType type = getBeanType(bean, beanName);
// 获取 @Validated 用于校验
Validated validated = getAnnotation(bean, beanName, Validated.class);
Annotation[] annotations = (validated == null ? new Annotation[] { annotation }
: new Annotation[] { annotation, validated });
// 创建 Bindable 对象
Bindable<?> target = Bindable.of(type).withExistingValue(bean)
.withAnnotations(annotations);
try {
// 绑定
this.configurationPropertiesBinder.bind(target);
}
catch (Exception ex) {
throw new ConfigurationPropertiesBindException(beanName, bean, annotation,
ex);
}
}
private ResolvableType getBeanType(Object bean, String beanName) {
// 先查找工厂方法
Method factoryMethod = this.beanFactoryMetadata.findFactoryMethod(beanName);
if (factoryMethod != null) {
// 如果存在,返回工厂方法的返回值类型
return ResolvableType.forMethodReturnType(factoryMethod);
}
// 否则返回 bean 的类型(难道 bean 的类型不是工厂方法的返回值类型吗?)
return ResolvableType.forClass(bean.getClass());
}
private <A extends Annotation> A getAnnotation(Object bean, String beanName,
Class<A> type) {
// 先到 @Bean 方法中获取
A annotation = this.beanFactoryMetadata.findFactoryAnnotation(beanName, type);
if (annotation == null) {
// 如果没有,再到类上获取
annotation = AnnotationUtils.findAnnotation(bean.getClass(), type);
}
return annotation;
}
}+
ConfigurationBeanFactoryMetadata
是用于在Bean
工厂初始化期间记住@Bean
定义元数据的实用程序类。在前面我们介绍过@ConfigurationProperties
不仅可以添加到类定义,还可以用于标注@Bean
方法,ConfigurationBeanFactoryMetadata
正是应用于在后者这类情况下获取@ConfigurationProperties
。+ +
public class ConfigurationBeanFactoryMetadata implements BeanFactoryPostProcessor {
private final Map<String, FactoryMetadata> beansFactoryMetadata = new HashMap<>();
// 后处理 BeanFactory
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
throws BeansException {
this.beanFactory = beanFactory;
// 遍历 Bean 定义
for (String name : beanFactory.getBeanDefinitionNames()) {
BeanDefinition definition = beanFactory.getBeanDefinition(name);
String method = definition.getFactoryMethodName();
String bean = definition.getFactoryBeanName();、
// 检测是否是一个 @Bean 方法对应的 Bean 定义
if (method != null && bean != null) {
this.beansFactoryMetadata.put(name, new FactoryMetadata(bean, method));
}
}
}
// 获取工厂方法上的注解
public <A extends Annotation> A findFactoryAnnotation(String beanName,
Class<A> type) {
Method method = findFactoryMethod(beanName);
return (method == null ? null : AnnotationUtils.findAnnotation(method, type));
}
// 获取工厂方法
public Method findFactoryMethod(String beanName) {
if (!this.beansFactoryMetadata.containsKey(beanName)) {
return null;
}
AtomicReference<Method> found = new AtomicReference<>(null);
FactoryMetadata metadata = this.beansFactoryMetadata.get(beanName);
Class<?> factoryType = this.beanFactory.getType(metadata.getBean());
String factoryMethod = metadata.getMethod();
// 如果是代理类,获取其父类
if (ClassUtils.isCglibProxyClass(factoryType)) {
factoryType = factoryType.getSuperclass();
}
// 遍历声明的方法进行匹配(名字相同即可没问题吗?)
ReflectionUtils.doWithMethods(factoryType, (method) -> {
if (method.getName().equals(factoryMethod)) {
found.compareAndSet(null, method);
}
});
return found.get();
}
}总结
+
@EnableConfigurationProperties
的目的有两个:+
+- 注册目标
+- 注册后处理器用于在目标进行
+Bean
初始化工作时,介入进行绑定尽管注册目标时的操作有些巧妙,但是还是要明白
+ConfigurationProperties
类只是单纯的被注册了而已。对于后处理器而言,无论一个ConfigurationProperties
类是不是通过注解注册,后处理器都会一视同仁地进行绑定。但同时,你又要知道后处理器也是通过@EnableConfigurationProperties
注册的,因此你需要保证至少有一个@EnableConfigurationProperties
标注的类被注册(并被处理了@Import
)。
在Spring Boot
中,@SpringBootApplication
通过@EnableAutoConfiguration
启用了自动配置,从而注册了ConfigurationPropertiesAutoConfiguration
,ConfigurationPropertiesAutoConfiguration
标注了@EnableConfigurationProperties
。因此,对于Spring Boot
而言,扫描范围内的所有ConfigurationProperties
类,其实都不需要@EnableAutoConfiguration
。事实上,由于默认生成的beanName
不同,多余的配置还会重复注册两个Bean
定义。+ +]]>
public class ConfigurationPropertiesAutoConfiguration {
}+ +java +spring +spring boot +- Spring AutowiredAnnotationBeanPostProcessor 的源码分析 /2023/12/08/source-code-analysis-of-AutowiredAnnotationBeanPostProcessor-in-Spring/ @@ -4640,65 +4722,6 @@spring - ConfigurationProperties 一定要搭配 EnableConfigurationProperties 使用吗 -/2023/12/10/is-it-necessary-to-use-ConfigurationProperties-with-EnableConfigurationProperties/ -- @ConfigurationProperties
和@EnableConfigurationProperties
是Spring Boot
中常用的注解,提供了方便和强大的外部化配置支持。尽管它们常常一起出现,但是它们真的必须一起使用吗?Spring Boot
的灵活性常常让我们忽略配置背后产生的作用究竟是什么?本文将从源码角度出发分析两个注解的作用时机和工作原理。 - - --
-- 本文的写作动机继承自Spring 中 @PropertySource 注解的使用和源码分析,两者有点相似并且常被一起提及,都通过外部配置管理运行时的属性值,但实际的工作原理却并不相同。
-- 本文没有介绍它们的使用方式,如有需要可以参考 Guide to @ConfigurationProperties in Spring Boot。
-- 理解
-@Import
的工作原理对阅读本文的源码有非常大的帮助,可以参考Spring 中 @Import 注解的使用和源码分析。注解
-
ConfigurationProperties
是用于外部化配置的注解。如果你想绑定和验证某些外部属性(例如来自.properties
文件),就将其添加到类定义或@Configuration
类中的@Bean
方法。请注意,和@Value
相反,SpEL
表达式不会被求值,因为属性值是外部化的。查看ConfigurationProperties
注解的源码可知,该注解主要起到标记和存储一些信息的作用。- -
public ConfigurationProperties {
// 可有效绑定到此对象的属性的名称前缀
String value() default "";
// 可有效绑定到此对象的属性的名称前缀
String prefix() default "";
// 绑定到此对象时是否忽略无效字段
boolean ignoreInvalidFields() default false;
// 绑定到此对象时是否忽略未知字段
boolean ignoreUnknownFields() default true;
}查看
-EnableConfigurationProperties
的源码,我们注意到它通过@Import
导入了EnableConfigurationPropertiesImportSelector
。- -
public EnableConfigurationProperties {
// 使用 Spring 快速注册标注了 @ConfigurationProperties 的 bean。无论 value 如何,标准的 Spring Bean 也将被扫描。
Class<?>[] value() default {};
}注解的作用
查看
-EnableConfigurationPropertiesImportSelector
的源码,关注selectImports
方法。该方法返回了ConfigurationPropertiesBeanRegistrar
和ConfigurationPropertiesBindingPostProcessorRegistrar
的全限定类名,Spring
将注册它们。- -
class EnableConfigurationPropertiesImportSelector implements ImportSelector {
private static final String[] IMPORTS = {
ConfigurationPropertiesBeanRegistrar.class.getName(),
ConfigurationPropertiesBindingPostProcessorRegistrar.class.getName() };
public String[] selectImports(AnnotationMetadata metadata) {
return IMPORTS;
}
}注册目标类
-
ConfigurationPropertiesBeanRegistrar
是一个内部类,查看ConfigurationPropertiesBeanRegistrar
的源码,关注registerBeanDefinitions
方法。注册的目标来自于:-
-- -
@EnableConfigurationProperties
的value
所指定的类中- 且标注了
-@ConfigurationProperties
的类- -
public static class ConfigurationPropertiesBeanRegistrar
implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
// 1. 获取要注册的 Class
// 2. 遍历获取的结果进行注册
getTypes(metadata).forEach((type) -> register(registry,
(ConfigurableListableBeanFactory) registry, type));
}
private List<Class<?>> getTypes(AnnotationMetadata metadata) {
// 获取所有 @EnableConfigurationProperties 的属性
MultiValueMap<String, Object> attributes = metadata
.getAllAnnotationAttributes(
EnableConfigurationProperties.class.getName(), false);
// 获取属性 value 的值,处理后返回
return collectClasses(attributes == null ? Collections.emptyList()
: attributes.get("value"));
}
// 收集 Class
private List<Class<?>> collectClasses(List<?> values) {
return values.stream().flatMap((value) -> Arrays.stream((Object[]) value))
.map((o) -> (Class<?>) o).filter((type) -> void.class != type)
.collect(Collectors.toList());
}
// 注册 Bean 定义
private void register(BeanDefinitionRegistry registry,
ConfigurableListableBeanFactory beanFactory, Class<?> type) {
String name = getName(type);
// 检测是否包含 Bean 定义
if (!containsBeanDefinition(beanFactory, name)) {
// 如果没找到,则注册
registerBeanDefinition(registry, name, type);
}
}
// beanName = prefix + 全限定类名 or 全限定类名
private String getName(Class<?> type) {
ConfigurationProperties annotation = AnnotationUtils.findAnnotation(type,
ConfigurationProperties.class);
String prefix = (annotation != null ? annotation.prefix() : "");
return (StringUtils.hasText(prefix) ? prefix + "-" + type.getName()
: type.getName());
}
// 检测是否包含 Bean 定义
private boolean containsBeanDefinition(
ConfigurableListableBeanFactory beanFactory, String name) {
// 先检测当前工厂中是否包含
if (beanFactory.containsBeanDefinition(name)) {
return true;
}
// 如果没找到则检测父工厂是否包含
BeanFactory parent = beanFactory.getParentBeanFactory();
if (parent instanceof ConfigurableListableBeanFactory) {
return containsBeanDefinition((ConfigurableListableBeanFactory) parent,
name);
}
return false;
}
// 注册 Bean 定义
private void registerBeanDefinition(BeanDefinitionRegistry registry, String name,
Class<?> type) {
// 断言目标类标注了 @ConfigurationProperties
assertHasAnnotation(type);
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(type);
// registry 注册 Bean 定义
registry.registerBeanDefinition(name, definition);
}
// 断言目标类标注了 @ConfigurationProperties
private void assertHasAnnotation(Class<?> type) {
Assert.notNull(
AnnotationUtils.findAnnotation(type, ConfigurationProperties.class),
"No " + ConfigurationProperties.class.getSimpleName()
+ " annotation found on '" + type.getName() + "'.");
}
}注册后处理器
查看
-ConfigurationPropertiesBindingPostProcessorRegistrar
的源码,关注registerBeanDefinitions
方法。该方法注册了ConfigurationPropertiesBindingPostProcessor
和ConfigurationBeanFactoryMetadata
。-
-- 前者顾名思义,用于处理
-ConfigurationProperties
的绑定- 后者是用于在
-Bean
工厂初始化期间记住@Bean
定义元数据的实用程序类- -
public class ConfigurationPropertiesBindingPostProcessorRegistrar
implements ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
if (!registry.containsBeanDefinition(
ConfigurationPropertiesBindingPostProcessor.BEAN_NAME)) {
registerConfigurationPropertiesBindingPostProcessor(registry);
registerConfigurationBeanFactoryMetadata(registry);
}
}
private void registerConfigurationPropertiesBindingPostProcessor(
BeanDefinitionRegistry registry) {
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(ConfigurationPropertiesBindingPostProcessor.class);
// Bean 定义的角色为基础设施
definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
registry.registerBeanDefinition(
ConfigurationPropertiesBindingPostProcessor.BEAN_NAME, definition);
}
private void registerConfigurationBeanFactoryMetadata(
BeanDefinitionRegistry registry) {
GenericBeanDefinition definition = new GenericBeanDefinition();
definition.setBeanClass(ConfigurationBeanFactoryMetadata.class);
definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
registry.registerBeanDefinition(ConfigurationBeanFactoryMetadata.BEAN_NAME,
definition);
}
}绑定
-
ConfigurationPropertiesBindingPostProcessor
是用于ConfigurationProperties
绑定的后处理器,关注afterPropertiesSet
方法还有核心方法postProcessBeforeInitialization
。-
-- 在
-afterPropertiesSet
方法中,它获取到了和自己一起注册的ConfigurationBeanFactoryMetadata
。- 在
-postProcessBeforeInitialization
方法中,先获取@ConfigurationProperties
,再进行绑定。- -
public class ConfigurationPropertiesBindingPostProcessor implements BeanPostProcessor,
PriorityOrdered, ApplicationContextAware, InitializingBean {
public static final String BEAN_NAME = ConfigurationPropertiesBindingPostProcessor.class
.getName();
public static final String VALIDATOR_BEAN_NAME = "configurationPropertiesValidator";
private ConfigurationBeanFactoryMetadata beanFactoryMetadata;
private ApplicationContext applicationContext;
private ConfigurationPropertiesBinder configurationPropertiesBinder;
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
this.applicationContext = applicationContext;
}
public void afterPropertiesSet() throws Exception {
// We can't use constructor injection of the application context because
// it causes eager factory bean initialization
// 注入了 ConfigurationBeanFactoryMetadata(不理解注解描述的情况?)
this.beanFactoryMetadata = this.applicationContext.getBean(
ConfigurationBeanFactoryMetadata.BEAN_NAME,
ConfigurationBeanFactoryMetadata.class);
this.configurationPropertiesBinder = new ConfigurationPropertiesBinder(
this.applicationContext, VALIDATOR_BEAN_NAME);
}
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 1;
}
public Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException {
// 获取 @ConfigurationProperties
ConfigurationProperties annotation = getAnnotation(bean, beanName,
ConfigurationProperties.class);
if (annotation != null) {
// 绑定
bind(bean, beanName, annotation);
}
return bean;
}
private void bind(Object bean, String beanName, ConfigurationProperties annotation) {
// 获取 Bean 的类型
ResolvableType type = getBeanType(bean, beanName);
// 获取 @Validated 用于校验
Validated validated = getAnnotation(bean, beanName, Validated.class);
Annotation[] annotations = (validated == null ? new Annotation[] { annotation }
: new Annotation[] { annotation, validated });
// 创建 Bindable 对象
Bindable<?> target = Bindable.of(type).withExistingValue(bean)
.withAnnotations(annotations);
try {
// 绑定
this.configurationPropertiesBinder.bind(target);
}
catch (Exception ex) {
throw new ConfigurationPropertiesBindException(beanName, bean, annotation,
ex);
}
}
private ResolvableType getBeanType(Object bean, String beanName) {
// 先查找工厂方法
Method factoryMethod = this.beanFactoryMetadata.findFactoryMethod(beanName);
if (factoryMethod != null) {
// 如果存在,返回工厂方法的返回值类型
return ResolvableType.forMethodReturnType(factoryMethod);
}
// 否则返回 bean 的类型(难道 bean 的类型不是工厂方法的返回值类型吗?)
return ResolvableType.forClass(bean.getClass());
}
private <A extends Annotation> A getAnnotation(Object bean, String beanName,
Class<A> type) {
// 先到 @Bean 方法中获取
A annotation = this.beanFactoryMetadata.findFactoryAnnotation(beanName, type);
if (annotation == null) {
// 如果没有,再到类上获取
annotation = AnnotationUtils.findAnnotation(bean.getClass(), type);
}
return annotation;
}
}-
ConfigurationBeanFactoryMetadata
是用于在Bean
工厂初始化期间记住@Bean
定义元数据的实用程序类。在前面我们介绍过@ConfigurationProperties
不仅可以添加到类定义,还可以用于标注@Bean
方法,ConfigurationBeanFactoryMetadata
正是应用于在后者这类情况下获取@ConfigurationProperties
。- -
public class ConfigurationBeanFactoryMetadata implements BeanFactoryPostProcessor {
private final Map<String, FactoryMetadata> beansFactoryMetadata = new HashMap<>();
// 后处理 BeanFactory
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
throws BeansException {
this.beanFactory = beanFactory;
// 遍历 Bean 定义
for (String name : beanFactory.getBeanDefinitionNames()) {
BeanDefinition definition = beanFactory.getBeanDefinition(name);
String method = definition.getFactoryMethodName();
String bean = definition.getFactoryBeanName();、
// 检测是否是一个 @Bean 方法对应的 Bean 定义
if (method != null && bean != null) {
this.beansFactoryMetadata.put(name, new FactoryMetadata(bean, method));
}
}
}
// 获取工厂方法上的注解
public <A extends Annotation> A findFactoryAnnotation(String beanName,
Class<A> type) {
Method method = findFactoryMethod(beanName);
return (method == null ? null : AnnotationUtils.findAnnotation(method, type));
}
// 获取工厂方法
public Method findFactoryMethod(String beanName) {
if (!this.beansFactoryMetadata.containsKey(beanName)) {
return null;
}
AtomicReference<Method> found = new AtomicReference<>(null);
FactoryMetadata metadata = this.beansFactoryMetadata.get(beanName);
Class<?> factoryType = this.beanFactory.getType(metadata.getBean());
String factoryMethod = metadata.getMethod();
// 如果是代理类,获取其父类
if (ClassUtils.isCglibProxyClass(factoryType)) {
factoryType = factoryType.getSuperclass();
}
// 遍历声明的方法进行匹配(名字相同即可没问题吗?)
ReflectionUtils.doWithMethods(factoryType, (method) -> {
if (method.getName().equals(factoryMethod)) {
found.compareAndSet(null, method);
}
});
return found.get();
}
}总结
-
@EnableConfigurationProperties
的目的有两个:-
-- 注册目标
-- 注册后处理器用于在目标进行
-Bean
初始化工作时,介入进行绑定尽管注册目标时的操作有些巧妙,但是还是要明白
-ConfigurationProperties
类只是单纯的被注册了而已。对于后处理器而言,无论一个ConfigurationProperties
类是不是通过注解注册,后处理器都会一视同仁地进行绑定。但同时,你又要知道后处理器也是通过@EnableConfigurationProperties
注册的,因此你需要保证至少有一个@EnableConfigurationProperties
标注的类被注册(并被处理了@Import
)。
在Spring Boot
中,@SpringBootApplication
通过@EnableAutoConfiguration
启用了自动配置,从而注册了ConfigurationPropertiesAutoConfiguration
,ConfigurationPropertiesAutoConfiguration
标注了@EnableConfigurationProperties
。因此,对于Spring Boot
而言,扫描范围内的所有ConfigurationProperties
类,其实都不需要@EnableAutoConfiguration
。事实上,由于默认生成的beanName
不同,多余的配置还会重复注册两个Bean
定义。- -]]>
public class ConfigurationPropertiesAutoConfiguration {
}- -java -spring -spring boot -当 MySQL 以 skip-name-resolve 模式启动时如何使用 grant 命令 /2023/12/13/how-to-grant-when-MySQL-started-with-skip-name-resolve-mode/ @@ -4871,1787 +4894,1764 @@- -Unsafe,一个“反 Java”的 class -/2023/12/25/Unsafe-an-anti-Java-class/ -Unsafe
类位于sun.misc
包中,它提供了一组用于执行低级别、不安全操作的方法。尽管Unsafe
类及其所有方法都是公共的,但它的使用受到限制,因为只有受信任的代码才能获取其实例。这个类通常被用于一些底层的、对性能敏感的操作,比如直接内存访问、CAS
(Compare and Swap
)操作等。本文将介绍这个“反Java
”的类及其方法的典型使用场景。 +synchronized 锁机制的分析和验证 +/2023/12/19/analysis-and-verification-of-the-synchronized-lock-mechanism/ +本文详细介绍了 -Java
中synchronized
锁的机制、存储结构、优化措施以及升级过程,并通过jol-core
演示Mark Word
的变化来验证锁升级的多个case
。--由于
-Unsafe
类涉及到直接内存访问和其他底层操作,使用它需要极大的谨慎,因为它可以绕过Java
语言的一些安全性和健壮性检查。在正常的应用程序代码中,最好避免直接使用Unsafe
类,以确保代码的可读性和可维护性。在一些特殊情况下,比如一些高性能库的实现,可能会使用Unsafe
类来进行一些性能优化。--尽管在生产中需要谨慎使用
-Unsafe
,但是可以在测试中使用它来更真实地接触Java
对象在内存中的存储结构,验证自己的理论知识。获取 Unsafe 实例
--在
+Java 9
及之后的版本中,Unsafe
类中的getUnsafe()
方法被标记为不安全(Unsafe
),不再允许普通的Java
应用程序代码通过此方法获取Unsafe
实例。这是为了提高Java
的安全性,防止滥用Unsafe
类的功能。待完善
在正常的
-Java
应用程序中,获取Unsafe
实例是不被推荐的,因为它违反了Java
语言的安全性和封装原则。Unsafe
类的设计本意是为了Java
库和虚拟机的实现使用,而不是为了普通应用程序开发者使用。Unsafe
对象为调用者提供了执行不安全操作的能力,它可用于在任意内存地址读取和写入数据,因此返回的Unsafe
对象应由调用者仔细保护。它绝不能传递给不受信任的代码。此类中的大多数方法都是非常低级的,并且对应于少量硬件指令。获取
-Unsafe
实例的静态方法如下:- -
public static Unsafe getUnsafe() {
Class<?> caller = Reflection.getCallerClass();
// 检查调用方法的类是被引导类加载器所加载
if (!VM.isSystemDomainLoader(caller.getClassLoader()))
throw new SecurityException("Unsafe");
return theUnsafe;
}-
Unsafe
使用单例模式,可以通过静态方法getUnsafe
获取Unsafe
实例,并且调用方法的类为启动类加载器所加载才不会抛出异常。获取Unsafe
实例有以下两种可行方案:-
-- 通过
--Xbootclasspath/a:${path}
把调用方法的类所在的jar
包路径追加到启动类路径中,使该类被启动类加载器加载。关于启动类路径的信息可以参考Java 类加载器源码分析 | ClassLoader 的搜索路径- 通过反射获取
-Unsafe
类中的Unsafe
实例
private static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}内存操作
+
Unsafe
类中包含了一些关于内存操作的方法,这些方法通常被认为是不安全的,因为它们可以绕过Java
语言的内置安全性和类型检查。以下是一些常见的Unsafe
类中关于内存操作的方法:利用
synchronized
实现同步的基础:Java
中的每一个对象都可以作为锁。具体表现为以下3
种形式。-
-- -
allocateMemory
: 分配一个给定大小(以字节为单位)的本地内存块,内容未初始化,通常是垃圾。生成的本地指针永远不会为零,并且将针对所有类型进行对齐。
public native long allocateMemory(long bytes);- -
reallocateMemory
: 将本地内存块的大小调整为给定大小(以字节为单位),超过旧内存块大小的内容未初始化,通常是垃圾。当且仅当请求的大小为零时,生成的本地指针才为零。传递给此方法的地址可能为空,在这种情况下将执行分配。
public native long reallocateMemory(long address, long bytes);- -
freeMemory
: 释放之前由allocateMemory
或reallocateMemory
分配的内存。
public native void freeMemory(long address);- -
setMemory
: 将给定内存块中的所有字节设置为固定值(通常为零)。
public native void setMemory(Object o, long offset, long bytes, byte value);
public void setMemory(long address, long bytes, byte value) {
setMemory(null, address, bytes, value);
}- -
copyMemory
: 复制指定长度的内存块
public native void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes);- -
putXxx
: 将指定偏移量处的内存设置为指定的值,其中Xxx
可以是Object
、int
、long
、float
和double
等。
public native void putObject(Object o, long offset, Object x);- -
getXxx
: 从指定偏移量处的内存读取值,其中Xxx
可以是Object
、int
、long
、float
和double
等。
public native Object getObject(Object o, long offset);- -
putXxx
和getXxx
也提供了按绝对基地址操作内存的方法。
public native byte getByte(long address);
public native void putByte(long address, byte x);从内存读取值时,除非满足以下情况之一,否则结果不确定:
--
-- 偏移量是通过
-objectFieldOffset
从字段的Field
对象获取的,o
指向的对象的类与字段所属的类兼容。- 偏移量和
-o
指向的对象(无论是否为null
)分别是通过staticFieldOffset
和staticFieldBase
从Field
对象获得的。- -
o
指向的是一个数组,偏移量是一个形式为B+N*S
的整数,其中N
是数组的有效索引,B
和S
分别是通过arrayBaseOffset
和arrayIndexScale
获得的值。--做一些“不确定”的测试,比如使用
-byte
相关的方法操作int
所在的内存块,是有意思且有帮助的,了解如何破坏,也可以更好地学习如何保护。分配堆外内存
在
-Java NIO
(New I/O
)中,分配堆外内存使用了Unsafe
类的allocateMemory
方法。堆外内存是一种在Java
虚拟机之外分配的内存,它不受Java
堆内存管理机制的控制。这种内存分配的主要目的是提高I/O
操作的性能,因为它可以直接与底层操作系统进行交互,而不涉及Java
堆内存的复杂性。Java 虚拟机的垃圾回收器虽然不直接管理这块内存,但是它通过一种称为“引用清理”(Reference Counting
)的机制来处理。- -
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 分配本地内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 初始化本地内存
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 使用虚引用 Cleaner 对象跟踪 DirectByteBuffer 对象的垃圾回收
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}当
-DirectByteBuffer
对象仅被Cleaner
对象(虚引用)引用时,它可以在任意一次GC
中被垃圾回收。在DirectByteBuffer
对象被垃圾回收后,Cleaner
对象会被加入到引用队列,ReferenceHandler
线程将调用Deallocator
对象的run
方法,从而实现本地内存的自动释放。- -
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 释放本地内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}CAS 相关
-
Unsafe
提供了3
个CAS
相关操作的方法,方法将内存位置的值与预期原值比较,如果相匹配,则CPU
会自动将该位置更新为新值,否则,CPU
不做任何操作。这些方法的底层实现对应着CPU
指令cmpxchg
。- -
// 如果 Java 变量当前符合预期,则自动将其更新为 x。
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);在
-AtomicInteger
的实现中,静态字段valueOffset
即为字段value
的内存偏移地址,valueOffset
的值在AtomicInteger
初始化时,在静态代码块中通过Unsafe
的objectFieldOffset
方法获取。- -
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
}CAS 更新变量的值的内存变化如下:
- - -配合
-ClassLayout
打印AtomicInteger
的内部结构更直观地感受offset
的含义:- - - -
java.util.concurrent.atomic.AtomicInteger object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf8003dbc
12 4 int AtomicInteger.value 1
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total参考文章
-]]>- -java -- -ComponentScan 扫描路径覆盖的真相 -/2023/12/11/the-truth-about-override-of-ComponentScan-basePackages/ -- @ComponentScan
注解是Spring
中很常用的注解,用于扫描并加载指定类路径下的Bean
,而Spring Boot
为了便捷使用@SpringBootApplication
组合注解集成了@ComponentScan
的能力。也许你听说过使用后者会覆盖前者中关于包扫描的设置,但你是否质疑过这个“不合常理”的结论?是否好奇过为什么它们不像其他注解在嵌套使用时可以同时生效?又是否好奇过@SpringBootApplication
可以间接设置@ComponentScan
属性的原因?本文从源码角度分析@ComponentScan
的工作原理,揭示它独特的检索算法和注解层次结构中的属性覆盖机制。 - - --
-- 本文的写作动机继承自Spring @Configuration 注解的源码分析,处理
-@ComponentScan
是处理@Configuration
过程的一部分。入口
对于标注了
-@ComponentScan
注解的配置类,处理过程如下:-
-- 获取
-@ComponentScan
的注解属性- 遍历注解属性集合,依次根据其中的信息进行扫描,获取
-Bean
定义- 如果获取到的
-Bean
定义中有任何其他配置类,将递归解析(处理配置类)--这里和处理
-@Import
的过程很像,都出现了递归解析新获得的配置类。- -
protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass)
throws IOException {
// ...
// 处理任何 @ComponentScan 注解
// 获取 @ComponentScan 的注解属性,该注解是可重复的
Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class);
if (!componentScans.isEmpty() &&
!this.conditionEvaluator.shouldSkip(sourceClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN)) {
// 遍历
for (AnnotationAttributes componentScan : componentScans) {
// 如果配置类被标注了 @ComponentScan -> 立即扫描
Set<BeanDefinitionHolder> scannedBeanDefinitions =
this.componentScanParser.parse(componentScan, sourceClass.getMetadata().getClassName());
// 检测被扫描到的 Bean 定义中是否有任何其他配置类,如有需要递归解析
for (BeanDefinitionHolder holder : scannedBeanDefinitions) {
if (ConfigurationClassUtils.checkConfigurationClassCandidate(
holder.getBeanDefinition(), this.metadataReaderFactory)) {
// 递归解析配置类
parse(holder.getBeanDefinition().getBeanClassName(), holder.getBeanName());
}
}
}
}
// ...
}扫描获取 Bean 定义
我们先跳过“获取
-@ComponentScan
的注解属性”的过程,来看“扫描获取Bean
定义”的过程。扫描是通过ComponentScanAnnotationParser
的parse
方法完成的,这个方法很长,但逻辑并不复杂,主要是为ClassPathBeanDefinitionScanner
设置一些来自@ComponentScan
的注解属性值,最终执行扫描。ClassPathBeanDefinitionScanner
顾名思义是基于类路径的Bean
定义扫描器,真正的扫描工作全部委托给了它。在这些设置过程中,我们需要关注basePackages
的设置:-
-- 使用 Set 存储合并结果,用于去重
-- 获取设置的
-basePackages
值并添加- 获取设置的
-basePackageClasses
值,转换为它们所在的包名并添加- 如果结果集现在还是空的,获取被标注的配置类所在的包名并添加
---最后一条规则就是“默认情况下扫描配置类所在的包”的说法由来,并且根据代码可知,如果主动设置了值,这条规则就不起作用了。
-- -
public Set<BeanDefinitionHolder> parse(AnnotationAttributes componentScan, final String declaringClass) {
Assert.state(this.environment != null, "Environment must not be null");
Assert.state(this.resourceLoader != null, "ResourceLoader must not be null");
// 创建 ClassPathBeanDefinitionScanner
ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(this.registry,
componentScan.getBoolean("useDefaultFilters"), this.environment, this.resourceLoader);
// Bean 名称生成器
Class<? extends BeanNameGenerator> generatorClass = componentScan.getClass("nameGenerator");
boolean useInheritedGenerator = (BeanNameGenerator.class == generatorClass);
scanner.setBeanNameGenerator(useInheritedGenerator ? this.beanNameGenerator :
BeanUtils.instantiateClass(generatorClass));
ScopedProxyMode scopedProxyMode = componentScan.getEnum("scopedProxy");
if (scopedProxyMode != ScopedProxyMode.DEFAULT) {
scanner.setScopedProxyMode(scopedProxyMode);
}
else {
Class<? extends ScopeMetadataResolver> resolverClass = componentScan.getClass("scopeResolver");
scanner.setScopeMetadataResolver(BeanUtils.instantiateClass(resolverClass));
}
scanner.setResourcePattern(componentScan.getString("resourcePattern"));
// 设置 Filter
for (AnnotationAttributes filter : componentScan.getAnnotationArray("includeFilters")) {
for (TypeFilter typeFilter : typeFiltersFor(filter)) {
scanner.addIncludeFilter(typeFilter);
}
}
for (AnnotationAttributes filter : componentScan.getAnnotationArray("excludeFilters")) {
for (TypeFilter typeFilter : typeFiltersFor(filter)) {
scanner.addExcludeFilter(typeFilter);
}
}
// 是否懒加载
boolean lazyInit = componentScan.getBoolean("lazyInit");
if (lazyInit) {
scanner.getBeanDefinitionDefaults().setLazyInit(true);
}
// 设置 basePackages(使用 Set 去重)
Set<String> basePackages = new LinkedHashSet<String>();
// 获取设置的 basePackages 值
String[] basePackagesArray = componentScan.getStringArray("basePackages");
for (String pkg : basePackagesArray) {
// 允许占位符
String[] tokenized = StringUtils.tokenizeToStringArray(this.environment.resolvePlaceholders(pkg),
ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
basePackages.addAll(Arrays.asList(tokenized));
}
// 获取 basePackageClasses,本质上是为了获取它们所在的包名
for (Class<?> clazz : componentScan.getClassArray("basePackageClasses")) {
basePackages.add(ClassUtils.getPackageName(clazz));
}
// 如果为空,获取被标注的配置类所在的包名
if (basePackages.isEmpty()) {
basePackages.add(ClassUtils.getPackageName(declaringClass));
}
// 排除被标注的配置类本身
scanner.addExcludeFilter(new AbstractTypeHierarchyTraversingFilter(false, false) {
protected boolean matchClassName(String className) {
return declaringClass.equals(className);
}
});
// 执行扫描
return scanner.doScan(StringUtils.toStringArray(basePackages));
}-
parse
方法与其说是解析,不如说是封装了一些设置并最终调用ClassPathBeanDefinitionScanner
,而设置的属性值来源于@ComponentScan
的注解属性。关于获取@ComponentScan
的注解属性的方法AnnotationConfigUtils.attributesForRepeatable
在分析@PropertySource
时也曾经遇到过,顾名思义我们知道它应该是用于获取可重复的注解的属性。可是它和直接获取注解对象有什么区别呢?--我们知道
-@SpringBootApplication
拥有和@ComponentScan
具备相似的功能,并且可以使用scanBasePackages
和scanBasePackageClasses
这两个属性设置扫描的包。也许你还知道@SpringBootApplication
之所以如此是因为它被标注了@ComponentScan
,scanBasePackages
和scanBasePackageClasses
分别是它的元注解@ComponentScan
中basePackages
和basePackageClasses
的别名。你甚至可能知道如果在配置类上使用@ComponentScan
设置包扫描后会导致@SpringBootApplication
设置的包扫描失效。
可是为什么呢?在Spring
中我们会看到从指定类上直接获取目标注解的代码,我们还会看到递归地从元注解上获取目标注解的代码,我们使用@ComponentScan
的经验告诉我们可重复注解不是覆盖彼此而是共同生效,那么为什么@SpringBootApplication
上的@ComponentScan
就被覆盖了呢?想当然的认为@SpringBootApplication
上标注了@ComponentScan
是一切的原因是不够的。- -
public SpringBootApplication {
Class<?>[] exclude() default {};
String[] excludeName() default {};
String[] scanBasePackages() default {};
Class<?>[] scanBasePackageClasses() default {};
}获取注解属性
-
attributesForRepeatable
方法有两个重载方法,最终调用的版本如下。先后处理了@ComponentScan
和@ComponentScans
。- -
static Set<AnnotationAttributes> attributesForRepeatable(AnnotationMetadata metadata,
String containerClassName, String annotationClassName) {
// Set 用于存储结果
Set<AnnotationAttributes> result = new LinkedHashSet<AnnotationAttributes>();
// 处理 @ComponentScan
addAttributesIfNotNull(result, metadata.getAnnotationAttributes(annotationClassName, false));
// 处理 @ComponentScans
Map<String, Object> container = metadata.getAnnotationAttributes(containerClassName, false);
if (container != null && container.containsKey("value")) {
for (Map<String, Object> containedAttributes : (Map<String, Object>[]) container.get("value")) {
addAttributesIfNotNull(result, containedAttributes);
}
}
return Collections.unmodifiableSet(result);
}检索注解的规则
根据注释,
-getAnnotationAttributes
方法检索给定类型的注解的属性,检索的目标可以是直接注解也可以是元注解,同时考虑组合注解上的属性覆盖。-
-- 元注解指的是标注在其他注解上的注解,用于对被标注的注解进行说明,比如
-@SpringBootApplication
上的@ComponentScan
就被称为元注解,此时@SpringBootApplication
被称为组合注解- 组合注解中存在属性覆盖现象
---其实这两点分别对应了我们想要探究的两个问题:
-@ComponentScan
究竟是如何被检索的?注解属性比如basePackages
又是如何被覆盖的?- -
public Map<String, Object> getAnnotationAttributes(String annotationName, boolean classValuesAsString) {
// 获取合并的注解属性
return (this.annotations.length > 0 ? AnnotatedElementUtils.getMergedAnnotationAttributes(
getIntrospectedClass(), annotationName, classValuesAsString, this.nestedAnnotationsAsMap) : null);
}根据注释,
-getMergedAnnotationAttributes
方法获取所提供元素上方的注解层次结构中指定的annotationName
的第一个注解,并将该注解的属性与注解层次结构较低级别中的注解中的匹配属性合并。注解层次结构中较低级别的属性会覆盖较高级别中的同名属性,并且完全支持单个注解中或是注解层次结构中的@AliasFor
语义。与getAllAnnotationAttributes
方法相反,一旦找到指定annotationName
的第一个注解,此方法使用的搜索算法将停止搜索注解层次结构。因此,指定的annotationName
的附加注解将被忽略。--这注释有点太抽象了,理解代码后再来回味吧。
-- -
public static AnnotationAttributes getMergedAnnotationAttributes(AnnotatedElement element,
String annotationName, boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
// 以 get 语义进行搜索(是指找到即终止搜索?)
AnnotationAttributes attributes = searchWithGetSemantics(element, null, annotationName,
new MergedAnnotationAttributesProcessor(classValuesAsString, nestedAnnotationsAsMap));
// 后处理注解属性
AnnotationUtils.postProcessAnnotationAttributes(element, attributes, classValuesAsString, nestedAnnotationsAsMap);
return attributes;
}-
searchWithGetSemantics
方法有多个重载方法,最终调用的版本如下:-
-- 先获取
-element
上的所有注解(包括重复的,不包括继承的),这意味着可重复注解@ComponentScan
标注了多个就会有多个实例- 在注解中搜索
-- 如果没找到,就从继承的注解中继续搜索
---本方法是一个会被递归调用的方法,在第一次调用时
-element
是配置类,之后就是注解。- -
private static <T> T searchWithGetSemantics(AnnotatedElement element,
Class<? extends Annotation> annotationType, String annotationName,
Class<? extends Annotation> containerType, Processor<T> processor,
Set<AnnotatedElement> visited, int metaDepth) {
// 防止无限递归
if (visited.add(element)) {
try {
// 获取 element 上的所有注解(包括重复,不包括继承的)
List<Annotation> declaredAnnotations = Arrays.asList(element.getDeclaredAnnotations());
// 在获得的注解中搜索
T result = searchWithGetSemanticsInAnnotations(element, declaredAnnotations,
annotationType, annotationName, containerType, processor, visited, metaDepth);
if (result != null) {
return result;
}
// 表明在直接声明的注解中没有找到
// 如果 element 是一个类
if (element instanceof Class) {
// 获取所有的注解(包括重复的和继承的)
List<Annotation> inheritedAnnotations = new ArrayList<>();
for (Annotation annotation : element.getAnnotations()) {
// 排除已经搜索过的,只留下继承的注解
if (!declaredAnnotations.contains(annotation)) {
inheritedAnnotations.add(annotation);
}
}
// 继续搜索
result = searchWithGetSemanticsInAnnotations(element, inheritedAnnotations,
annotationType, annotationName, containerType, processor, visited, metaDepth);
if (result != null) {
return result;
}
}
}
catch (Throwable ex) {
AnnotationUtils.handleIntrospectionFailure(element, ex);
}
}
return null;
}遍历注解进行搜索。
--
-- 先在注解中搜索,这意味着如果配置类标注了
-@ComponentScan
,直接就找到了- 如果没找到再在元注解中搜索,如果配置类只标注了
+@SpringBootApplication
,就是在这部分找到元注解@ComponentScan
- 对于普通同步方法,锁是当前实例对象。
+- 对于静态同步方法,锁是当前类的
+Class
对象。- 对于同步方法块,锁是
synchronized
括号里配置的对象。--严格意义上说,并不是直接标注的
-@ComponentScan
会覆盖@SpringBootApplication
上间接标注的@ComponentScan
,而是搜索在找到第一个注解后终止没有继续查找。这解答了我们的第一个疑问。- -
private static <T> T searchWithGetSemanticsInAnnotations( AnnotatedElement element,
List<Annotation> annotations, Class<? extends Annotation> annotationType,
String annotationName, Class<? extends Annotation> containerType,
Processor<T> processor, Set<AnnotatedElement> visited, int metaDepth) {
// 遍历注解进行查找,如果同时标注 @SpringBootApplication 和 @ComponentScan,在这部分就会找到 @ComponentScan 就返回了
for (Annotation annotation : annotations) {
// 获取注解的 Class
Class<? extends Annotation> currentAnnotationType = annotation.annotationType();
// 检测是否属于 Java 语言注解包中(以 java.lang.annotation 开头)的注解,例如 @Documented,是的话跳过
if (!AnnotationUtils.isInJavaLangAnnotationPackage(currentAnnotationType)) {
// 检测是否满足条件:等于 annotationType(传入 null),或者和目标的名字(@ComponentScan 全限定类名)相同,或者属于总是处理(默认 false)
if (currentAnnotationType == annotationType ||
currentAnnotationType.getName().equals(annotationName) ||
processor.alwaysProcesses()) {
// 处理注解获得注解属性
T result = processor.process(element, annotation, metaDepth);
if (result != null) {
// processor.aggregates() 默认返回 false
if (processor.aggregates() && metaDepth == 0) {
processor.getAggregatedResults().add(result);
}
else {
// 注意:难道标注多个 @ComponentScan 也只找到一个就返回了?
return result;
}
}
}
// 容器里的可重复注解,因为 containerType 为 null,跳过
else if (currentAnnotationType == containerType) {
for (Annotation contained : getRawAnnotationsFromContainer(element, annotation)) {
T result = processor.process(element, contained, metaDepth);
if (result != null) {
// No need to post-process since repeatable annotations within a
// container cannot be composed annotations.
processor.getAggregatedResults().add(result);
}
}
}
}
}
// 在元注解中递归的搜索,@SpringBootApplication 中的 @ComponentScan 就是在这找到的
for (Annotation annotation : annotations) {
// 获取注解的 Class
Class<? extends Annotation> currentAnnotationType = annotation.annotationType();
// 检测是否属于 Java 语言注解包中
if (!AnnotationUtils.isInJavaLangAnnotationPackage(currentAnnotationType)) {
// 递归到元注解中搜索,深度加 1
T result = searchWithGetSemantics(currentAnnotationType, annotationType,
annotationName, containerType, processor, visited, metaDepth + 1);
if (result != null) {
// 进行后处理,注解层次结构中较低级别的属性会覆盖较高级别中的同名属性就是在这发生的
processor.postProcess(element, annotation, result);
if (processor.aggregates() && metaDepth == 0) {
processor.getAggregatedResults().add(result);
}
else {
return result;
}
}
}
}
return null;
}处理
-@ComponentScan
获得AnnotationAttributes
。- -
public AnnotationAttributes process(int metaDepth) { AnnotatedElement annotatedElement, Annotation annotation,
return AnnotationUtils.retrieveAnnotationAttributes(annotatedElement, annotation,
this.classValuesAsString, this.nestedAnnotationsAsMap);
}以
-AnnotationAttributes
映射的形式检索给定注解的属性。- -
static AnnotationAttributes retrieveAnnotationAttributes( Object annotatedElement, Annotation annotation,
boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
Class<? extends Annotation> annotationType = annotation.annotationType();
AnnotationAttributes attributes = new AnnotationAttributes(annotationType);
// 遍历属性方法
for (Method method : getAttributeMethods(annotationType)) {
try {
// 获取属性值
Object attributeValue = method.invoke(annotation);
// 获取默认值
Object defaultValue = method.getDefaultValue();
// 如果默认值不为 null 且和属性值相同
if (defaultValue != null && ObjectUtils.nullSafeEquals(attributeValue, defaultValue)) {
attributeValue = new DefaultValueHolder(defaultValue);
}
// 属性名 -> 属性值
attributes.put(method.getName(),
adaptValue(annotatedElement, attributeValue, classValuesAsString, nestedAnnotationsAsMap));
}
catch (Throwable ex) {
if (ex instanceof InvocationTargetException) {
Throwable targetException = ((InvocationTargetException) ex).getTargetException();
rethrowAnnotationConfigurationException(targetException);
}
throw new IllegalStateException("Could not obtain annotation attribute value for " + method, ex);
}
}
return attributes;
}
// 获取在所提供的 annotationType 中声明的与 Java 对注释属性的要求相匹配的所有方法
static List<Method> getAttributeMethods(Class<? extends Annotation> annotationType) {
// 先从缓存中获取
List<Method> methods = attributeMethodsCache.get(annotationType);
if (methods != null) {
return methods;
}
// 遍历方法筛选
methods = new ArrayList<>();
for (Method method : annotationType.getDeclaredMethods()) {
if (isAttributeMethod(method)) {
ReflectionUtils.makeAccessible(method);
methods.add(method);
}
}
// 存入缓存
attributeMethodsCache.put(annotationType, methods);
return methods;
}
// 确定提供的方法是否是注解的属性方法。
static boolean isAttributeMethod( { Method method)
// 无参数 && 返回值非 void
return (method != null && method.getParameterCount() == 0 && method.getReturnType() != void.class);
}组合注解的属性覆盖
在获得注解属性后还要进行后处理,使用注解层次结构中较低级别的属性覆盖较高级别中的同名(包括
-@AliasFor
指定的)属性。比如使用@SpringBootApplication
中的scanBasePackages
的值覆盖@ComponentScan
中的basePackages
的值。- -
public void postProcess( { AnnotatedElement element, Annotation annotation, AnnotationAttributes attributes)
annotation = AnnotationUtils.synthesizeAnnotation(annotation, element);
// 获取 AnnotationAttributes 的注解类型(@ComponentScan)
Class<? extends Annotation> targetAnnotationType = attributes.annotationType();
// Track which attribute values have already been replaced so that we can short
// circuit the search algorithms.
Set<String> valuesAlreadyReplaced = new HashSet<>();
// 获取注解的属性方法(SpringBootApplication)
for (Method attributeMethod : AnnotationUtils.getAttributeMethods(annotation.annotationType())) {
String attributeName = attributeMethod.getName();
// 获取被覆盖的别名
String attributeOverrideName = AnnotationUtils.getAttributeOverrideName(attributeMethod, targetAnnotationType);
// Explicit annotation attribute override declared via @AliasFor
if (attributeOverrideName != null) {
// 被覆盖的属性的值是否已经被替换
if (valuesAlreadyReplaced.contains(attributeOverrideName)) {
continue;
}
List<String> targetAttributeNames = new ArrayList<>();
targetAttributeNames.add(attributeOverrideName);
valuesAlreadyReplaced.add(attributeOverrideName);
// 确保覆盖目标注解中的所有别名属性。 (SPR-14069)
List<String> aliases = AnnotationUtils.getAttributeAliasMap(targetAnnotationType).get(attributeOverrideName);
if (aliases != null) {
for (String alias : aliases) {
if (!valuesAlreadyReplaced.contains(alias)) {
targetAttributeNames.add(alias);
valuesAlreadyReplaced.add(alias);
}
}
}
overrideAttributes(element, annotation, attributes, attributeName, targetAttributeNames);
}
// Implicit annotation attribute override based on convention
else if (!AnnotationUtils.VALUE.equals(attributeName) && attributes.containsKey(attributeName)) {
overrideAttribute(element, annotation, attributes, attributeName, attributeName);
}
}
}
// 根据提供的注解属性方法的 @AliasFor,获取被覆盖的属性的名称
static String getAttributeOverrideName(Method attribute, { Class<? extends Annotation> metaAnnotationType)
// 获取别名描述符
AliasDescriptor descriptor = AliasDescriptor.from(attribute);
// 从元注解中被覆盖的属性名
return (descriptor != null && metaAnnotationType != null ?
descriptor.getAttributeOverrideName(metaAnnotationType) : null);
}
// 获取在提供的注解类型中通过 @AliasFor 声明的所有属性别名的映射。该映射由属性名称作为键,每个值代表别名属性的名称列表。空返回值意味着注解没有声明任何属性别名。
static Map<String, List<String>> getAttributeAliasMap( { Class<? extends Annotation> annotationType)
if (annotationType == null) {
return Collections.emptyMap();
}
// 从缓存中获取
Map<String, List<String>> map = attributeAliasesCache.get(annotationType);
if (map != null) {
return map;
}
map = new LinkedHashMap<>();
// 遍历属性方法
for (Method attribute : getAttributeMethods(annotationType)) {
// 获取别名列表
List<String> aliasNames = getAttributeAliasNames(attribute);
if (!aliasNames.isEmpty()) {
map.put(attribute.getName(), aliasNames);
}
}
// 存入缓存
attributeAliasesCache.put(annotationType, map);
return map;
}
// 获取通过提供的注解属性的 @AliasFor 配置的别名属性的名称列表
static List<String> getAttributeAliasNames(Method attribute) {
AliasDescriptor descriptor = AliasDescriptor.from(attribute);
return (descriptor != null ? descriptor.getAttributeAliasNames() : Collections.<String> emptyList());
}
// 覆盖属性
private void overrideAttributes( AnnotatedElement element, Annotation annotation,
AnnotationAttributes attributes, String sourceAttributeName, List<String> targetAttributeNames) {
Object adaptedValue = getAdaptedValue(element, annotation, sourceAttributeName);
// 遍历目标属性中的所有应被覆盖的属性(本尊+别名)
for (String targetAttributeName : targetAttributeNames) {
attributes.put(targetAttributeName, adaptedValue);
}
}在代码的注释中我们留下过一个疑问,如果找到了第一个注解就立即返回,那么标注了多个
-@ComponentScan
呢?当你Debug
时,你会发现并没有走出现直接标注了@ComponentScan
的处理,其实看到反编译后的代码你就知道了,多个@ComponentScan
被合成了一个@ComponentScans
,甚至此时设置的三个basePackages
都是生效的。在JDK 8
引入的重复注解机制,并非一个语言层面上的改动,而是编译器层面的改动。在编译后,多个可重复注解@ComponentScan
会被合并到一个容器注解@ComponentScans
中。--因此,“
-@ComponentScan
的配置会覆盖@SpringBootApplication
关于包扫描的配置”这句话既对又不对,它在一个常见的个例上表现出的现象是对的,在更普遍的情况中以及本质上是错误的。你也许可以再根据一些情况罗列出类似的“@ComponentScan
使用规则”,但是如果你不明白背后的本质,那么这些只是一些死记硬背的陈述,甚至会带给你错误的认知。- -
// 标注了两个 `@ComponentScan`,对编译后的字节码进行反编译
public class DemoApplication {
public DemoApplication() {
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}注解内的别名属性
-
postProcess
方法完成了组合注解的属性覆盖,可是对于@ComponentScan
注解而言,它没有被postProcess
方法处理,它又是如何做到设置basePackages
等于设置value
呢?其实这发生在后处理注解属性方法中,该方法会对注解中标注了@AliasFor
的属性强制执行别名语义。通俗地讲,就是统一或校验互为别名的属性值,要么只设置了其中一个属性的值,其他别名属性会被赋值为相同的值,要么设置为相同的值,否则会报错。- -
public static AnnotationAttributes getMergedAnnotationAttributes(AnnotatedElement element,
String annotationName, boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
// 以 get 语义进行搜索(是指找到即终止搜索?)
AnnotationAttributes attributes = searchWithGetSemantics(element, null, annotationName,
new MergedAnnotationAttributesProcessor(classValuesAsString, nestedAnnotationsAsMap));
// 后处理注解属性
AnnotationUtils.postProcessAnnotationAttributes(element, attributes, classValuesAsString, nestedAnnotationsAsMap);
return attributes;
}
static void postProcessAnnotationAttributes( Object annotatedElement,
boolean classValuesAsString, boolean nestedAnnotationsAsMap) { AnnotationAttributes attributes,
if (attributes == null) {
return;
}
// 获取 AnnotationAttributes 的注解类型(@ComponentScan)
Class<? extends Annotation> annotationType = attributes.annotationType();
// Track which attribute values have already been replaced so that we can short
// circuit the search algorithms.
Set<String> valuesAlreadyReplaced = new HashSet<>();
if (!attributes.validated) {
// 校验 @AliasFor 配置
// 获取别名映射
Map<String, List<String>> aliasMap = getAttributeAliasMap(annotationType);
// 遍历
for (String attributeName : aliasMap.keySet()) {
// 跳过已处理的
if (valuesAlreadyReplaced.contains(attributeName)) {
continue;
}
Object value = attributes.get(attributeName);
// 属性是否已有值
boolean valuePresent = (value != null && !(value instanceof DefaultValueHolder));
// 遍历属性的别名列表
for (String aliasedAttributeName : aliasMap.get(attributeName)) {
// 跳过已处理的
if (valuesAlreadyReplaced.contains(aliasedAttributeName)) {
continue;
}
// 获取别名属性的值
Object aliasedValue = attributes.get(aliasedAttributeName);
// 别名属性是否已有值
boolean aliasPresent = (aliasedValue != null && !(aliasedValue instanceof DefaultValueHolder));
// Something to validate or replace with an alias?
if (valuePresent || aliasPresent) {
// 如果属性已有值且别名属性也有值,校验是否相等
if (valuePresent && aliasPresent) {
// Since annotation attributes can be arrays, we must use ObjectUtils.nullSafeEquals().
if (!ObjectUtils.nullSafeEquals(value, aliasedValue)) {
String elementAsString =
(annotatedElement != null ? annotatedElement.toString() : "unknown element");
throw new AnnotationConfigurationException(String.format(
"In AnnotationAttributes for annotation [%s] declared on %s, " +
"attribute '%s' and its alias '%s' are declared with values of [%s] and [%s], " +
"but only one is permitted.", attributes.displayName, elementAsString,
attributeName, aliasedAttributeName, ObjectUtils.nullSafeToString(value),
ObjectUtils.nullSafeToString(aliasedValue)));
}
}
else if (aliasPresent) {
// 复制别名属性的值给属性
attributes.put(attributeName,
adaptValue(annotatedElement, aliasedValue, classValuesAsString, nestedAnnotationsAsMap));
valuesAlreadyReplaced.add(attributeName);
}
else {
// 复制属性的值给别名属性
attributes.put(aliasedAttributeName,
adaptValue(annotatedElement, value, classValuesAsString, nestedAnnotationsAsMap));
valuesAlreadyReplaced.add(aliasedAttributeName);
}
}
}
}
// 校验完毕
attributes.validated = true;
}
// 将 `value` 从 `DefaultValueHolder` 替换为原始的 `value`
for (String attributeName : attributes.keySet()) {
if (valuesAlreadyReplaced.contains(attributeName)) {
continue;
}
Object value = attributes.get(attributeName);
if (value instanceof DefaultValueHolder) {
value = ((DefaultValueHolder) value).defaultValue;
attributes.put(attributeName,
adaptValue(annotatedElement, value, classValuesAsString, nestedAnnotationsAsMap));
}
}
}总结
--又是一篇在写之前自认心里有数,以为可以很快总结完,却不知不觉写了很久,也收获了很多的文章。在刚开始,我只是想接续分析
-@Configuration
的思路补充关于@ComponentScan
的内容,但是渐渐地我又想要回应心里的疑问,@ComponentScan
和@SpringBootApplication
一起使用的问题的本质原因是什么?Spring
框架真的很好用,好用到你不用太关心背后的原理,好用到你有时候用一个本质上不太正确的结论“走遍天下却几乎不会遇到问题”。说实话,研究完也有点索然无味,尤其是花了这么多时间看自己很讨厌的关于解析的代码,只能说解开了一个卡点也算疏通了一口气,但是时间成本好大啊,得多看点能“面试”的技术啊!!!综上分析,
+@SpringBootApplication
的包扫描功能本质上还是@ComponentScan
提供的,但是和常见的嵌套注解不同,检索@ComponentScan
有一套独特的算法,导致@SpringBootApplication
和@ComponentScan
并非简单的叠加效果。当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
-
-]]>- -
Spring
会先获取@ComponentScan
的注解属性再获取@ComponentScans
的注解属性- 以
-@ComponentScan
为例,只获取给定配置类上的注解层次结构中的第一个@ComponentScan
- 先从直接标注的注解开始,再递归地搜索元注解,这一点决定了
-@ComponentScan
优先级高于@SpringBootApplication
- 使用注解层次结构中较低级别的属性覆盖较高级别的同名(支持
-@AliasFor
)属性,这一点决定了@SpringBootApplication
可以设置扫描路径- 多个
+@ComponentScan
在编译后隐式生成@ComponentScans
,这一点决定多个@ComponentScan
彼此之间以及和@SpringBootApplication
互不冲突- 在
+JVM
层面,synchronized
锁是基于进入和退出Monitor
来实现的,每一个对象都有一个Monitor
与之相关联。- 在字节码层面,同步方法块是使用
monitorenter
和monitorexit
指令实现的,前者在编译后插入到同步方法块的开始位置,后者插入到同步方法块的结束位置和异常位置。- -java -spring -spring boot -- -Ubuntu server 20.04 安装后没有分配全部磁盘空间 -/2023/06/24/Ubuntu-server-20-04-not-all-disk-space-was-allocated-after-installation/ -使用 -VMware
安装Ubuntu server 20.04
,注意到实际文件系统的总空间大小仅占设置的虚拟磁盘空间大小的一半左右。本文介绍了如何解决该问题。 - - --最近在本地测试
+Kubesphere
和Minikube
,使用Ubuntu server 20.04
搭建了多个虚拟机,磁盘空间紧张。注意到在安装后,实际文件系统的总空间大小仅占设置的虚拟磁盘空间大小的一半左右。如果Ubuntu server 20.04
安装时使用默认的LVM
选项,就会出现这种情况。存储结构
+-锁存在哪里呢?锁里面又会存储什么信息呢?
解决步骤
-
-- 使用
-df -h
命令显示文件系统的总空间和可用空间信息。分配了40G
磁盘空间,可用仅19G
。
df -h
Filesystem Size Used Avail Use% Mounted on
udev 3.9G 0 3.9G 0% /dev
tmpfs 792M 7.5M 785M 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 19G 17G 995M 95% /
tmpfs 3.9G 0 3.9G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 3.9G 0 3.9G 0% /sys/fs/cgroup
/dev/sda2 2.0G 108M 1.7G 6% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/1828
/dev/loop2 50M 50M 0 100% /snap/snapd/18357
/dev/loop1 92M 92M 0 100% /snap/lxd/24061
tmpfs 792M 0 792M 0% /run/user/1000
/dev/loop3 54M 54M 0 100% /snap/snapd/19457- 使用
-sudo vgdisplay
命令查看发现Free PE / Size
还有19G
。
sudo vgdisplay
--- Volume group ---
VG Name ubuntu-vg
System ID
Format lvm2
Metadata Areas 1
Metadata Sequence No 2
VG Access read/write
VG Status resizable
MAX LV 0
Cur LV 1
Open LV 1
Max PV 0
Cur PV 1
Act PV 1
VG Size <38.00 GiB
PE Size 4.00 MiB
Total PE 9727
Alloc PE / Size 4863 / <19.00 GiB
Free PE / Size 4864 / 19.00 GiB
VG UUID NuEjzH-CKXm-W6lA-gqzj-4bds-IR1Y-dTZ8IP- 使用
-sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
调整逻辑卷的大小。
sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
Size of logical volume ubuntu-vg/ubuntu-lv changed from <19.00 GiB (4863 extents) to <38.00 GiB (9727 extents).
Logical volume ubuntu-vg/ubuntu-lv successfully resized.- 使用
-sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
调整文件系统的大小。
sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
resize2fs 1.45.5 (07-Jan-2020)
Filesystem at /dev/mapper/ubuntu--vg-ubuntu--lv is mounted on /; on-line resizing required
old_desc_blocks = 3, new_desc_blocks = 5
The filesystem on /dev/mapper/ubuntu--vg-ubuntu--lv is now 9960448 (4k) blocks long.- 使用
-df -h
命令再次查看,确认文件系统的总空间大小调整为38G
。
df -h
Filesystem Size Used Avail Use% Mounted on
udev 3.9G 0 3.9G 0% /dev
tmpfs 792M 7.5M 785M 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 38G 17G 19G 47% /
tmpfs 3.9G 0 3.9G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 3.9G 0 3.9G 0% /sys/fs/cgroup
/dev/sda2 2.0G 108M 1.7G 6% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/1828
/dev/loop2 50M 50M 0 100% /snap/snapd/18357
/dev/loop1 92M 92M 0 100% /snap/lxd/24061
tmpfs 792M 0 792M 0% /run/user/1000
/dev/loop3 54M 54M 0 100% /snap/snapd/19457
/dev/loop4 64M 64M 0 100% /snap/core20/1950参考链接
ubuntu20.04 server 安装后磁盘空间只有一半的处理
-]]>
Ubuntu Server 20.04.1 LTS, not all disk space was allocated during installation?- -linux -ubuntu -- 谈谈 MySQL 事务的隔离性 -/2024/01/06/talk-about-isolation-of-MySQL-transactions/ -事务就是一组数据库操作,它具有原子性( +Atomicity
)、一致性(Consistency
)、隔离性(Isolation
)和持久性(Durability
),简称为ACID
。本文将介绍MySQL
事务的隔离性以及对其的思考。
尽管这是一个老生常谈的话题,网上也有很多相关的资料,但是要理解它并不容易。即使林晓斌老师在 《MySQL 实战 45 讲》 中用了两个章节进行介绍,但是你在评论区中会发现有些分享或讨论的观点彼此矛盾。原因可能有很多,比如为了易于理解使用简化概念进行分析,有些具体细节各人各执一词同时它们又不好通过测试进行验证,用词不严谨等等。本文尽可能为自己梳理出一个完善并且前后一致的认知体系,再针对一些容易引起误解的地方作进一步的说明。 - - -隔离级别
+
SQL
标准的事务隔离级别包括:读未提交(read uncommitted
)、读提交(read committed
)、可重复读(repeatable read
)和串行化(serializable
)。当多个事务同时执行时,不同的隔离级别可能发生脏读(dirty read
)、不可重复读(non-repeatable read
)、幻读(phantom read
)等一个或多个现象。隔离级别越高,效率越低,因此很多时候,我们需要在二者之间寻找一个平衡点。对象头
+
synchronized
用的锁是存在Java
对象头(object header
)里的。如果对象是数组类型,则虚拟机用3
字宽(Word
)存储对象头,如果对象是非数组类型,则用2
字宽存储对象头。在32
位虚拟机中,1
字宽等于4
字节,即32bit
。在64
位虚拟机中,1
字宽等于8
字节,即64bit
。
Java
对象头的组成结构如下:-
- 隔离级别 -脏读 -不可重复读 -幻读 +长度 +内容 +说明 - -读未提交 -Y -Y -Y -- 读提交 -N -Y -Y ++ 32/64bit
+ Mark Word
存储对象的 hashCode
或锁信息- 可重复读 -N -N -Y ++ 32/64bit
+ Class Metadata Address
存储指向对象类型数据的指针 - 串行化 -N -N -N ++ 32/64bit
+ Array length
数组的长度(如果当前对象是数组) --读未提交和串行化很少在实际应用中使用。
-通过以下示例说明隔离级别的影响,
+V1
、V2
和V3
在不同隔离级别下的值有所不同。Mark Word
+
Java
对象头里的Mark Word
里默认存储对象的HashCode
,分代年龄和锁标记位。在运行期间,Mark Word
里存储的数据会随着锁标志位的变化而变化。Mark Word
可能变化为另外4
种数据。以
32
位虚拟机为例:- -
+ +- - -事务 A -事务 B -读未提交 -读提交 -可重复读 -串行化 -- -开启事务 -开启事务 -- - - - - -查询得到值 1 -- - - - - - +- 查询得到值 1 -- - - - + +锁状态 +25bit +4bit +1bit +2bit ++ +23bit +2bit +是否是偏向锁 +锁标志位 ++ +无锁状态 +对象的 hashCode +对象分代年龄 +0 +01 ++ +偏向锁 +线程 ID +Epoch +对象分代年龄 +1 +01 ++ +轻量级锁 +指向栈中锁记录的指针 +00 ++ +重量级锁 +指向互斥量(重量级锁)的指针 +10 ++ +GC 标记 +空 +11 +以
+64
位虚拟机为例:+
+ ++ +锁状态 +56bit +1bit +4bit +1bit +2bit ++ +25bit +31bit +- +- +是否是偏向锁 +锁标志位 ++ +无锁状态 +unused +对象的 hashCode +cms_free +对象分代年龄 +0 +01 ++ +偏向锁 +线程 ID(54bit) | Epoch(2bit) +cms_free +对象分代年龄 +1 +01 ++ +轻量级锁 +指向栈中锁记录的指针 +00 ++ +重量级锁 +指向互斥量(重量级锁)的指针 +10 ++ +GC 标记 +空 +11 +++在上述表述中,很容易让人产生困惑的地方是
+hashCode
和分代年龄是对象的固有属性,当Mark Word
中存储的数据发生变化时,这些重要的数据去哪了?内部结构可视化
“百闻不如一见”,
+jol-core
提供了打印对象内部结构的能力。+
+- 添加依赖,新版本比旧版本打印结果的可读性更好
+
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>${org.openjdk.jol.version}</version>
</dependency>- 使用
+ClassLayout.parseInstance(objectExample).toPrintable()
打印
public class ObjectInternalTest {
private byte aByte;
private int aInt;
public static void main(String[] args) {
ObjectInternalTest objectInternalTest = new ObjectInternalTest();
log.info(ClassLayout.parseInstance(objectInternalTest).toPrintable());
}
}- 打印结果:
+mark|class|fields|alignment
。这样我们就能通过查看Mark Word
的值更直观地确定当前锁的状态。
2023-12-23 20:21:02 - com.moralok.concurrency.ch2.ObjectExample object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00060828
12 4 int ObjectExample.aInt 0
16 1 byte ObjectExample.aByte 0
17 7 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total指针压缩和 cms_free
注意到指向对象类型数据的指针仅
+ +4
个字节,这是因为默认情况下JVM
参数UseCompressedOops
是启用的。+ +
|--------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (96 bits) | State |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (32 bits) | |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record | lock:2 | OOP to metadata object | Lightweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor | lock:2 | OOP to metadata object | Heavyweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|使用
+-XX:-UseCompressedOops
关闭指针压缩,指向对象类型数据的指针才会变回8
个字节+ +
|------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (128 bits) | State |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (64 bits) | |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | OOP to metadata object | Lightweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | Heavyweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|------------------------------------------------------------------------------|-----------------------------|--------------------|你可能还会注意到开启和关闭指针压缩时,还有一个
+bit
从cms_free
变成unused
。这个cms_free
是做什么用的呢?在未开启指针压缩的情况下,指针的低位因为内存对齐的缘故往往是0
,我们可以给这些bit
设置1
用于标记特殊状态。CMS
将Klass
指针的最低位设置为1
用于表示特定的内存块不是一个对象,而是空闲的内存。在开启指针压缩后,JVM
通过右移移除指针中没用到的低位,因此CMS
需要一个地方存储这个表示是否为空闲内存的bit
,就是cms_free
。++这在一定程度上解决了我心中的一个问题:
+JVM
是怎么判断一个空闲的内存块的?concurrentMarkSweepGeneration.cpp
++ +
// A block of storage in the CMS generation is always in
// one of three states. A free block (FREE), an allocated
// object (OBJECT) whose size() method reports the correct size,
// and an intermediate state (TRANSIENT) in which its size cannot
// be accurately determined.
// STATE IDENTIFICATION: (32 bit and 64 bit w/o COOPS)
// -----------------------------------------------------
// FREE: klass_word & 1 == 1; mark_word holds block size
//
// OBJECT: klass_word installed; klass_word != 0 && klass_word & 1 == 0;
// obj->size() computes correct size
//
// TRANSIENT: klass_word == 0; size is indeterminate until we become an OBJECT
//
// STATE IDENTIFICATION: (64 bit+COOPS)
// ------------------------------------
// FREE: mark_word & CMS_FREE_BIT == 1; mark_word & ~CMS_FREE_BIT gives block_size
//
// OBJECT: klass_word installed; klass_word != 0;
// obj->size() computes correct size
//
// TRANSIENT: klass_word == 0; size is indeterminate until we become an OBJECT使用
+java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
开启HotSpot Debugger
,比对ClassLayout
打印的Klass
指针和Class Browser
中的指针。+
+- + +- 修改值为 2 -- - - + 指针压缩 +关闭 +开启 ++ ClassLayout +0xf800c105 +0x00000245eb873d20 - 查询得到值 V1 -- 2(读到B未提交的修改) -1 -1 -1 +二进制表达 +11111000000000001100000100000101 +00100100010111101011100001110011110100100000 - - 提交事务 -- - - + HotSpot Debugger +0x00000007c0060828 +0x00000245EB873D20 - +查询得到值 V2 -- 2 -2(读到B已提交的修改) -1 -1 +二进制表达 +011111000000000001100000100000101000 +00100100010111101011100001110011110100100000 对象分代年龄
通过以下示例可以测试和验证对象分代年龄的变化。
++ +
public static void main(String[] args) throws InterruptedException {
log.info("测试 Mark Word 中的分代年龄");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态,age: 0");
log.info(ClassLayout.parseInstance(lock).toPrintable());
System.gc();
TimeUnit.SECONDS.sleep(1);
log.info("GC 后 =====> 无锁状态,age: 1");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}重量级锁
锁优化
+
Java 6
为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java 6
中,锁一共有4
种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,锁的状态会随着竞争的激化逐渐升级。锁状态可以升级但不能降级,举例来说偏向锁状态升级成轻量级锁状态后不能降级成偏向锁状态。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。++上述的表述并不容易理解,甚至容易让人产生误解。锁状态描述的是锁本身的状态,和是否处于加锁状态无关。以下列表格举例说明,一个偏向锁状态的对象,即使未加锁,也是偏向锁状态,而非无锁状态。
++
-- + +提交事务 -- - - - + 层次 +未加锁 +加锁 ++ 1 +匿名偏向锁状态 or 偏向锁状态 +偏向锁状态 - 查询得到值 V3 -2 -2 -2(A在事务期间数据一致) -1 +无锁状态 +轻量级锁状态 - 补充说明 -- - - - B的修改阻塞至A提交 +3 +重要级锁状态 +重要级锁状态 通过测试验证以上结论可以帮助你更直观地感受隔离级别的作用:
--
-- 新建连接
-mysql –h localhost –u root -P 3306 –p
- 查看会话的事务隔离级别
-show variables like 'transaction_isolation';
- 设置会话的事务隔离级别
-set session transaction isolation level read uncommitted|read committed|repeatable read|serializable;
- 测试和验证
--
mysql> show variables like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+---
5.7
引入了transaction_isolation
作为tx_isolation
的别名,8.0.3
废弃后者。了解数据库的隔离级别及其影响对于理解自身正在使用的数据库的行为、根据业务场景设置隔离级别优化性能以及迁移数据都是有帮助的。
-Oracle
数据库的默认隔离级别是“读提交”,MySQL
的默认隔离级别是“可重复读”。事务隔离的实现
--在
+MySQL
中,事务隔离是通过lock
、undo log
和read view
共同协作实现的。很多时候,我们关注 MVCC 在“读提交”和“可重复读”隔离级别中的作用而忽视事务隔离和锁的关系。在查阅的众多资料中,关于锁升级过程的介绍并不详尽和准确,虽然大体上大家的观点是比较一致的,但是在一些细节的描述上却有些模糊不清,有些观点自相矛盾,有些观点互相矛盾,有些观点和我的知识或者测试结果矛盾,甚至有些逻辑不通顺以至于不能相互联系形成和谐的整体。以下内容尽可能结合相对权威和详细的资料,补充个人的思考和猜想作为缝合剂,并通过一些测试用例验证部分猜想,试图建立更加连续平滑以及可信服的知识面。
+
MySQL
各个事务隔离级别的实现原理简述如下:锁升级变化图
提前放出锁升级变化图,用于在后续分析和测试过程中对照查看。重点关注以下可能引起锁状态变化的事件:
++
+ + +- 获取锁和释放锁
+- 竞争,其中弱竞争是指线程交替进入同步块,没有发生直接冲突;强竞争是指线程在同步块内的时候有其他线程想要进入同步块
+- 调用特殊方法,比如计算
+hashCode
(非自定义)或者wait
方法偏向锁
+
HotSpot
的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁在
+Java 6
之后是默认开启的,可以通过JVM
参数-XX:-UseBiasedLocking
关闭偏向锁。尽管偏向锁是默认开启的,但是它在应用程序启动几秒钟之后才激活,延迟时间可以通过JVM
参数-XX:BiasedLockingStartupDelay
设置,默认情况下是4000ms
。测试偏向锁配置
延迟偏向
通过以下示例测试并验证延迟偏向。
++ +
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:偏向锁是延迟激活的");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
// 默认情况下偏向延迟的设置为 -XX:BiasedLockingStartupDelay=4000
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
log.info("偏向锁激活之后,新创建的对象的对象头的 Mark Word 是 =====> 匿名偏向锁");
Object biasedLock = new Object();
log.info(ClassLayout.parseInstance(biasedLock).toPrintable());
log.info("偏向锁激活之前创建的对象的对象头的 Mark Word 仍然是 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}测试结果如下:
++
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)-
-- 串行化:读加共享锁,写加排他锁,读写互斥
-- 读未提交:写加排他锁,读不加锁
-- 可重复读:第一次读操作时创建快照,基于该快照进行读取
-- 读提交:每次读操作时重置快照,基于该快照进行读取
+- +
JVM
启动后,偏向锁尚未激活前,创建的对象的Mark Word
的末尾3
位为0|01
,non-biasable
,表示无锁状态(非可偏向的)。- 在
+4000
毫秒后,新创建的对象的Mark Word
的末尾3
位为1|01
,biasable
,表示匿名偏向锁(可偏向的)。- 偏向锁尚未激活前创建的对象的对象头的
Mark Word
的末尾3
位仍然是0|01
。前两者通过锁(
-lock
)实现比较容易理解;后两者通过多版本并发控制(MVCC
)实现。MVCC
是一种实现非阻塞并发读的设计思路,在InnoDB
引擎中主要通过undo log
和read view
实现。以下示意图表现了在
-InnoDB
引擎中,同一行数据存在多个“快照”版本,这就是数据库的多版本并发控制(MVCC
),当你基于快照读取时可以获得旧版本的数据。-
- - -- 假设一个值从 1 按顺序被修改为 2、3、4,最新值为 4。
-- 事务将基于各自拥有的“快照”读取数据而不受其他事务更新的影响,也不阻塞其他事务的更新。
-在接下来我们将通过锁、事务
-ID
、回滚日志和一致性视图逐步介绍InnoDB
事务隔离的实现原理。锁(lock)
事务在本质上是一个并发控制问题,而锁是解决并发问题的常见基础方案。
MySQL
正是通过共享锁和排他锁实现串行化隔离级别。但是读加共享锁影响性能,尤其是在读写冲突频繁时,若不加发生“脏读”的缺陷又比较大,MVCC
就是用于在即使有读写冲突的情况下,不加读锁实现非阻塞并发读。--在
+InnoDB
的事务中,行锁(共享锁或排他锁)是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这个就是两阶段锁协议。在虚拟机启动后,偏向锁激活前,创建的对象的锁标记位为
1|01
,此时记录线程ID
的bit
全是0
(代表指向null
),没有偏向任何一个线程,该状态称之为匿名偏向锁。理解两阶段锁协议,你会更深地体会读写冲突频繁时锁对性能的影响以及
-MVCC
的作用。长事务可能导致一个锁被长时间持有,导致拖垮整个库。事务 ID
在
- +InnoDB
引擎中,每个事务都有唯一的一个事务ID
,叫做transaction id
。它是在事务开始的时候向InnoDB
的事务系统申请的,是按申请顺序严格递增的。同时每一行数据有一个隐藏字段trx_id
,记录了插入或更新该行数据的事务ID
。关闭偏向延迟
通过以下示例测试关闭偏向延迟:
+-
// JVM 参数设置为 -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:关闭偏向锁的延迟偏向");
Object lock = new Object();
log.info("在虚拟机一启动,新创建的对象的对象头的 Mark Word 就是 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}创建事务的时机
事务启动方式如下:
--
-- 显式启动事务语句是
-begin
或start transaction
,配套的提交语句是commit
,回滚语句是rollback
。- 隐式启动事务语句是
-set autocommit = 0
,该设置将关闭自动提交。当你执行select
,将自动启动一个事务,直到你主动commit
或rollback
。但注意,实际上不论是显式启动事务情况下的
-begin
或start transaction
,还是隐式启动事务情况下的commit
或rollback
都不会立即创建一个新事务,而是直到第一次操作InnoDB
表的语句执行时,才会真正创建一个新事务。可以通过以下语句查看当前“活跃”的事务进行验证:
-+
select * from information_schema.innodb_trx;关闭偏向锁
通过以下示例测试关闭偏向锁:
+-
// JVM 参数设置为 -XX:-UseBiasedLocking
public static void main(String[] args) throws InterruptedException {
log.info("测试:关闭偏向锁");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("sleep 4000ms");
TimeUnit.MILLISECONDS.sleep(4000);
log.info("即使过了偏向延迟时间,创建的对象的对象头的 Mark Word 仍然是 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}--只读事务的事务
-ID
和更新事务不同。--可以使用
-commit work and chain;
在提交的同时开启下一次事务,减少一次begin;
指令的交互开销。回滚日志(undo log)
在
+InnoDB
引擎中,每条记录在更新的时候都会同时记录一条回滚操作。记录的最新值,通过回滚操作,可以得到之前版本的值。它的作用是:《Java 并发编程的艺术》中写的是
+-XX:-UseBiasedLocking=false
,测试中报错:+ +
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
Improperly specified VM option 'UseBiasedLocking=false'另外书中说“在关闭偏向锁后程序默认会进入轻量级锁状态”,个人认为可能会让人产生误解,默认在未获取锁时为无锁状态,获取锁将变为轻量级锁状态。
+偏向锁加锁
当一个线程访问同步块时,先测试
Mark Word
里是否存储着当前线程ID
:-
-- 数据回滚:当事务回滚或者数据库崩溃时,通过
-undolog
进行数据回滚。- 多版本并发控制:当读取一行记录时,如果该行记录已经被其他事务修改,通过
+undo log
读取之前版本的数据,以此实现非阻塞并发读。- 如果否,则再测试
+Mark Word
中偏向锁的标识是否设置成1
+
+- 如果为
+0
,则说明不是偏向锁状态 =====> 获取偏向锁失败后续处理一- 如果为
+1
,则说明是偏向锁状态,通过CAS
操作设置偏向锁+
+- 如果成功,说明获得偏向锁
+- 如果失败,说明发生竞争 =====> 获取偏向锁失败后续处理二
+- 如果是,则说明当前线程就是之前获得偏向锁的线程,此刻再次获得锁
实际上,每一行数据还有一个隐藏字段
- - -roll_ptr
。很多相关资料简单地描述“roll_ptr
用于指向该行数据的上一个版本”,但是该说法容易让人误解旧版本的数据是物理上真实存在的,好像有一张链表结构的历史记录表按顺序记录了每一个版本的数据。有些资料会特地强调旧版本的数据不是物理上真实存在的,
- - -undo log
是逻辑日志,记录了与实际操作语句相反的操作,旧版本的数据是通过undo log
计算得到的。--说实话,在不了解细节的前提下,通过计算得到旧版本的数据更加反直觉。总而言之,
-InnoDB
的数据总是存储最新版本,尽管该版本所属的事务可能尚未提交;任何事务其实都是从最新版本开始回溯,直到获得该事务认为可见的版本。回滚日志的删除时机
回滚日志不会一直保留,在没有事务需要的时候,系统会自动判断和删除。基于该结论,我们应该避免使用长事务。长事务意味着系统里面可能会存在很老的
-read view
,这些事务可能访问数据库里的任何数据,所以在这个事务提交之前,数据库里它可能用到的回滚日志都必须保留,这就会导致大量存储空间被占用。在MySQL 5.5
及之前的版本中,回滚日志是和数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,但只是代表那部分存储空间可复用,文件并不会变小,需要重建整个库才能解决问题。一致性视图(read view)
一致性读视图(
read view
)又可以称之为快照(snapshot
),它是基于整库的,但是它并不是真的拷贝了整个数据库的数据,否则随着数据量的增长,显然无法实现秒级创建快照。read view
可以理解为发出一个声明:“以我创建的时刻为准,如果一个数据版本所属的事务是在这之前提交的,就可见;如果是在这之后提交的,就不可见,需要回溯上一个版本判断,重复直到获得可见的版本;如果该数据版本属于当前事务自身,是可见的”。--以上声明类似于功能的需求描述,它比具体实现更简洁和易于理解。
+在通过
CAS
操作设置偏向锁中,Compare
操作是“测试Mark Word
存储线程ID
的bit
是否全部为0
,代表偏向的线程ID
为null
”,Swap
操作是将当前线程ID
设置到Mark Word
的相应位置。“快照”结合“多版本”等词,和
+undo log
的情况类似很容易让人误解为有一个物理上真实存在的数据快照,但实际上read view
只是在沿着数据版本链回溯时用于判断该版本对当前事务是否可见的依据。在具体实现上,InnoDB
为每一个事务构造了一个数组用于保存创建read view
时,当前正在“活跃”的所有事务ID
,其中“活跃”指的是启动了但尚未提交。数组中事务ID
的最小值记为低水位,当前系统里面已经创建过的事务ID
的最大值加 1 记为高水位。这个数组和高水位就组成了当前事务的一致性视图(read view
)。对于当前事务的read view
而言,一个数据版本的trx_id
,有以下几种可能:补充思考:
-
-- 如果小于低水位,表示这个版本是已提交的事务生成的,可见
-- 如果大于等于高水位,表示这个版本是创建
-read view
之后启动的事务,不可见- 如果大于等于低水位且小于高水位
+-
-- 如果这个版本的
-trx_id
在数组中,表示这个版本是已启动但尚未提交的事务生成的,不可见- 如果这个版本的
-trx_id
不在数组中,表示这个版本是已提交的事务生成的,可见- “通过
+CAS
操作将当前线程ID
设置到Mark Word
”在偏向锁状态下是有且仅有一次的“偏向”动作。(此观点存疑,在《Java 并发编程的艺术》一书中有“重新偏向于其他线程”这样的描述,但是关于竞争偏向锁部分的原理难以理解。个人在测试中,不论是持有偏向锁的线程仍存活但已离开同步块,还是已死亡,后续线程都无法再获取到偏向锁,唯一一种不同线程获取到同一个偏向锁的情况是两个线程可以复用同一个局部变量表槽位,它们的tid
相同,这代表着本质上Mark Word
并无变化)- 当获得偏向锁的线程离开同步块时,没有“解锁操作”,
+Mark Word
维持不变。个人也不知道如何更准确地描述这个现象,从synchronized
的语义来说,进出同步块代表着获取锁和释放锁;但是从偏向锁的实现来说,即便离开同步方法块,它仍然偏向原先获得锁的线程,甚至在讨论偏向锁发生竞争时,书中提到“检查持有偏向锁的线程是否存活”。个人更倾向于使用“撤销锁”一词描述偏向锁面临竞争时的处理,使用“释放锁”描述线程离开同步块时的处理。- 当获得偏向锁的线程再次访问同步块时,简单测试
+Mark Word
里存储着当前线程ID
,如果成功即可进入同步块。- 计算过
hashCode
后偏向锁状态会变为其他状态,比如无锁状态,或者升级为轻量级锁甚至重量级锁,这符合CAS
操作的判断条件。+
InnoDB
利用“所有数据都有多个版本,每个版本都记录了所属事务ID
”这个特性,实现了“秒级创建快照”的能力。有了这个能力,系统里面随后发生的更新,就和当前事务可见的数据无关了,当前事务读取时也不必再加锁。偏向锁撤销
偏向锁使用了一种等到竞争出现才撤销的机制,当获得偏向锁的线程离开同步块时,并没有“解锁操作”,
Mark Word
将维持不变。当竞争出现时,从现象上说,如果持有偏向锁的线程已经离开同步块,则锁升级为轻量级锁;如果持有锁的线程尚未离开同步块,则锁直接升级为重量级锁。--以上“具体实现”相较于之前的“需求描述”显得有些啰嗦和复杂,然而这里的细节是值得推敲的。即便是林晓斌老师在《MySQL 实战 45 讲》中的详细讲解也让部分读者包括我本人感到困惑。
+关于偏向锁的撤销,其原理晦涩难懂,个人仍有很多疑问:锁记录中存储偏向的线程
ID
的作用,检查持有偏向锁的线程是否存活的作用不符合测试结果,重新偏向于其他线程的复现条件。因为理解有限,不多赘述。林晓斌老师的数据版本可见性示意图如下,容易让人产生误解的地方在于三段式的划分给人一种已提交的事务全都是小于低水位的错觉。
- +测试偏向锁升级
匿名偏向锁->偏向锁
++在一个匿名偏向锁状态的对象第一次被作为锁获取时,
+Mark Word
就会从匿名偏向锁变成偏向锁,并且再也不会变回到匿名偏向锁。测试在匿名偏向锁状态下获取锁将变成偏向锁状态:
+-
public static void main(String[] args) throws IOException, InterruptedException {
log.info("偏向锁基础测试:匿名偏向锁 -> 偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("{} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,输入任意字符回车继续,可以使用 jstack 查看线程 tid 和 Mark Word 进行对比");
scanner.next();
}
log.info("偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 仍然不变");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}事实上,已提交事务的分布可能如下,大部分人的疑问其实只是“在大于等于低水位小于高水位的范围中,为什么会有已提交的事务”。
- +测试结果如下:
+-
2023-12-21 00:34:39 - 偏向锁基础测试:匿名偏向锁 -> 偏向锁
2023-12-21 00:34:39 - sleep 4000ms,等待偏向锁激活
2023-12-21 00:34:43 - Mark Word 初始为 =====> 匿名偏向锁
2023-12-21 00:34:45 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2023-12-21 00:34:45 - main 获取锁 =====> 偏向锁
2023-12-21 00:34:45 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000028761af3005 (biased: 0x00000000a1d86bcc; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2023-12-21 00:34:45 - 暂停,输入任意字符回车继续,可以使用 jstack 查看线程 tid 和 Mark Word 进行对比
2023-12-21 00:34:55 - 偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 仍然不变
2023-12-21 00:34:55 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000028761af3005 (biased: 0x00000000a1d86bcc; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total要理解该问题需要理解另外一个问题——“创建
-read view
的时机”。创建 read view 的时机
很多资料介绍“可重复读”隔离级别下的
-read view
创建时机为在事务启动时,但这并不严谨,还会导致理解read view
数组困难。创建事务并不等于创建read view
。--官方文档:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTED isolation level, the snapshot is reset to the time of each consistent read operation.
--
-- 对于“读提交”隔离级别,每次读操作都会重置快照。这意味着只要当前事务持续足够长的时间,它最后读取时完全可能熬到在它之前甚至之后创建的事务提交。
-- 对于“可重复读”隔离级别,在第一次执行快照读时创建快照。这意味着当前事务可以执行很多次以及很久的
-update
语句后再执行读取,熬到在它之前甚至之后创建的事务提交。有些人可能想到了前者,但对于后者存疑或者不知道如何验证,其实测试并不复杂:
+通过
+jstack
获取线程tid
(以Windows
为例):+ +
jps | findstr "BiasedLockingBaseTest" | ForEach-Object { jstack $_.Split()[0]} | findstr "main"
"main" #1 prio=5 os_prio=0 tid=0x0000028761af3000 nid=0x8668 waiting on condition [0x000000ff7b8ff000]
at com.moralok.concurrency.ch2.BiasedLockingBaseTest.main(BiasedLockingBaseTest.java:27)关注
Mark Word
并转换为二进制表达:+
- 事务 A -事务 B ++ 二进制表达 - -- begin;
- begin;
- -- update t set k = 2 where id = 2;
(创建事务)- - - + update t set k = 666 where id = 1;
(创建事务)匿名偏向锁 +Mark Word
00000000000000000000000000000000000000000101 - - + commit;
偏向锁状态 +Mark Word
00101000011101100001101011110011000000000101 - - select * from t where id = 1;
(创建read view
,k = 666)+ + biased
00000000000010100001110110000110101111001100 - - commit;
+ + main
线程tid
00101000011101100001101011110011000000000000 +
+- 注意:存储的所谓“线程
+ID
”并非平时所说的线程ID
,该值左移可以得到jstack
的返回结果中的tid
,jol-core
打印了一个名为biased
的值与之相同- 在离开同步方法块后,
+Mark Word
不变偏向锁->轻量级锁
测试当拥有偏向锁的线程已经离开同步块,其他线程尝试获取偏向锁(弱竞争),锁将升级为轻量级锁。
++ +
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("测试:当持有偏向锁的线程已经离开同步块,其他线程尝试获取偏向锁时,将获得轻量级锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.SECONDS.sleep(4);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
Thread thread = new Thread(() -> {
log.info("第二个线程 {} 尝试获取锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("第二个线程 {} 获取锁 =====> 轻量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread.start();
thread.join();
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}有相关资料提到在拥有偏向锁的线程死亡后,锁可以偏向新的线程,但是验证失败。
++ +
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:之前获得偏向锁的线程已死时,新线程获得的仍然是偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread1.start();
Thread thread2 = new Thread(() -> {
try {
thread1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
boolean alive = thread1.isAlive();
log.info("第一个线程 {} 是否存活 {}", thread1.getName(), alive);
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("即使第一个线程已死亡,第二个线程 {} 获取锁 =====> 轻量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁->重量级锁
测试当拥有偏向锁的线程尚未离开同步块,其他线程尝试获取偏向锁(强竞争),锁将升级为重量级锁。
++ +
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("测试:当持有偏向锁的线程尚未离开同步块,其他线程尝试获取偏向锁时,将升级为重量级锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.SECONDS.sleep(4);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,输入任意字符回车继续");
scanner.next();
log.info("第一个线程 {} 持有偏向锁,在同步块内发生竞争 =====> 升级为重量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("第一个线程 {} 结束", Thread.currentThread().getName());
}, "thread1");
thread1.start();
TimeUnit.SECONDS.sleep(1);
Thread thread2 = new Thread(() -> {
log.info("第二个线程 {} 尝试获取偏向锁失败", Thread.currentThread().getName());
synchronized (lock) {
log.info("第二个线程 {} 获取锁 =====> 重量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁->偏向锁(特例)
这是一个很奇怪的测试用例,它是在测试中唯一发生不同线程对同一个锁获得偏向锁的情况。但是排查过程中发现两个线程的
tid
相同,猜测是局部变量表槽位复用时有什么优化机制。--因此,严谨地说,创建事务的时机和创建一致性视图的时机是不同的。通过
+start transaction with consistent snapshot;
可以在开启事务的同时立即创建read view
。卡了我好久,也没有探究到实质的新信息。
当前读和快照读
现在我们知道在
-InnoDB
引擎中,一行数据存在多个版本。MVCC
使得在“可重复读”隔离级别下的事务好像与世无争。但是在以下示例中,事务 B 是在事务 A 的一致性视图之后创建和提交的,为什么事务 A 查询到的k
为 3 呢?- -
-- - -事务 A -事务 B -- -- start transaction with consistent snapshot;
(k = 1)- - -- - update t set k = k + 1 where id = 1;
(自动提交事务)- -- update t set k = k + 1 where id = 1;
(当前读)- - -- select * from t where id = 1;
(k = 3)- - -- commit;
- 其实,更新数据是先读后写的,并且是“当前读”。
++ +
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:之前获得偏向锁的线程已死时,新线程获得的仍然是偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread1.start();
thread1.join();
Thread thread2 = new Thread(() -> {
synchronized (lock) {
log.info("第二个线程 {} 获取锁,=====> 偏向锁", Thread.currentThread().getName());
log.info("震惊!!!为什么两个 tid 相同啊,有什么复用机制吗");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 不变");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}匿名偏向锁状态计算 hashCode
在匿名偏向锁状态计算
+hashCode
,锁将变为无锁状态。+ +
public static void main(String[] args) throws InterruptedException {
log.info("测试:在匿名偏向锁状态计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁状态无锁时计算 hashCode
在偏向锁状态无锁时计算
+hashCode
,锁将变为无锁状态。+ +
public static void main(String[] args) throws InterruptedException {
log.info("测试:在偏向锁状态无锁时计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
int hashCode = lock.hashCode();
log.info("离开同步块后再计算 hashCode:Mark Word =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁状态加锁时计算 hashCode
在偏向锁状态加锁时计算
+hashCode
,锁将升级为重量级锁状态。+ +
public static void main(String[] args) throws InterruptedException {
log.info("测试:在偏向锁状态计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}轻量级锁
轻量级锁加锁
获取偏向锁失败后续处理一(是否是偏向锁为
0
):-
- 当前读:读取一行数据的最新版本,并保证在读取时其他事务不能修改该行数据,因此需要在读取时加锁。以下操作属于当前读的情况:
-
+- 共享锁:
-select lock in share mode
- 排他锁:
+select for update
,update
,insert
,delete
- 检测锁标志位是否为
+01
或者00
+
-- 如果否,则说明是重量级锁状态 =====> 获取轻量级锁失败后续处理一
+- 如果是,则说明是无锁状态或者轻量级锁状态,尝试通过
-CAS
操作设置轻量级锁+
- 如果成功,说明获得轻量级锁
+- 如果失败,说明发生竞争 =====> 获取轻量级锁失败后续处理二
- 快照读:在不加锁的情况下通过
select
读取一行数据,但和“读未提交”隔离级别下单纯地读取最新版本不同,它是基于一个“快照”进行读取。因此在事务 A 中更新时,读取到的是事务 B 更新后的最新值,在事务 A 更新后,依据
-read view
的可见性原则,它可以看到自身事务的更新后的最新值 3。如果事务 B 尚未提交的情况下,事务 A 发起更新,会如何呢?这时候就轮到“两阶段锁协议”派上用场了:
+++在通过
+CAS
操作设置轻量级锁中,Compare
操作是“测试Mark Word
的锁标志位是否为01
,代表处于无锁状态”,Swap
操作是将Mark Word
复制到栈中锁记录,并将指向栈中锁记录的指针设置到Mark Word
的相应位置以及修改锁标志位。所谓“栈中锁记录”又称为Displaced Mark Word
,JVM
会在当前线程的栈帧中创建用于存储锁记录的空间,用于在轻量级锁状态下临时存放Mark Word
。++在轻量级锁状态下,明确提及了锁记录的作用,但偏向锁状态下,提及锁记录却并未加以解释。
+获取偏向锁失败后续处理一:
-
-- 事务 B 在更新时,对改行数据加排他锁,在事务 B 提交时才会释放
-- 当事务 A 发起更新,将阻塞直到事务 B 提交
+- 已经升级为重量级锁
- -
-- - -事务 A -事务 B -- -- start transaction with consistent snapshot;
(k = 1)- - -- - begin;
- -- - update t set k = k + 1 where id = 1;
(排他锁)- -- update t set k = k + 1 where id = 1;
(阻塞至 B 提交)- - -- - commit;
- -- select * from t where id = 1;
(k = 3)- - -- commit;
- 至此,我们将锁和
-MVCC
在事务隔离的实现原理中串联起来了。两者是互相独立又互相协作的两个机制,前者实现了“当前读”,后者实现了“快照读”。总结
卡壳好几天,想到有不少好的文章却仍然会给读者留下困惑,想到自己在当初学习时对一些不严谨的表达抓耳挠腮想不通为什么,就有点不知道如何下笔。最终围绕着自己当初的一些困惑,一点一点修修补补完了。
-参考文章
-
- 03 | 事务隔离:为什么你改了我还看不见
-- 08 | 事务到底是隔离的还是不隔离的?
+获取偏向锁失败后续处理二(通过
+CAS
加偏向锁失败):+
-]]>- 获取锁失败的线程将锁升级为重量级锁,修改
+Mark Word
为指向互斥量(重量级锁)的指针|10
(这个操作将影响到持有轻量级锁的线程的解锁)- 线程阻塞,等待唤醒
补充思考:
++
+- 有相关资料提及偏向锁并非直接升级到重量级锁,无法验证是否总是有轻量级锁作为中间状态
+- 轻量级锁面临竞争时升级为重量级锁的过程相比于偏向锁面临竞争时的升级过程,更加容易理解,后者好多细节没有找到令人信服的答案。
+轻量级锁解锁
轻量级锁解锁时,会通过
+CAS
操作解锁,Compare
操作是“测试Mark Word
的锁标志位是否为00
,代表处于轻量级锁状态,Swap
操作是将栈中锁记录Dispaced Mark Word
替换回对象头的Mark Word
以及修改锁标志位。
如果Compare
操作失败,则代表发生竞争,此时锁已经被其他线程升级为重量级锁以及Mark Word
被修改为指向互斥量(重量级锁)的指针|10
。持有轻量级锁的线程会释放锁(直接将Dispaced Mark Word
替换回Mark Word
?)并唤醒等待的线程,开启新的一轮争抢。测试轻量级锁升级
无锁->轻量级锁
测试在无锁状态下获取锁,锁将变成轻量级锁状态。
++ +
public static void main(String[] args) throws IOException, InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("轻量级锁基础测试:无锁状态 -> 轻量级锁");
Object lock = new Object();
log.info("在偏向锁激活之前创建的对象为 =====> 无锁状态(可偏向额)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("即使是单线程无竞争获取锁,=====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,回车继续");
scanner.nextLine();
}
log.info("离开同步块后,-> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}无锁状态计算 hashCode
在无锁状态计算
+hashCode
,仍然是无锁状态。+ +
public static void main(String[] args) throws InterruptedException {
log.info("测试:在无锁状态计算 hashCode");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}轻量级锁加锁时计算 hashCode
在轻量级锁状态加锁时计算
+hashCode
,锁将升级为重量级锁状态。+ +
public static void main(String[] args) throws InterruptedException {
log.info("测试:在轻量级锁状态计算 hashCode");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}参考文章
+
+]]>- 《Java 并发编程的艺术》
+- 难搞的偏向锁终于被 Java 移除了
+- Details about mark word of java object header
+- mysql +java +lock +synchronized - 不使用 GParted 的情况下为 VMware 中的 Ubuntu Server 增大磁盘空间 -/2024/01/14/increase-disk-space-of-Ubuntu-server-on-VMware-without-using-GParted/ -GParted
是一款适用于Linux
的图形化磁盘分区管理工具,通过它可以便捷地为VMware
中的Ubuntu Desktop
增大磁盘空间。然而你可能正在使用Ubuntu Server
,并不想要安装或并不被允许安装图形化界面,本文介绍了如何在不使用GParted
的情况下,通过命令行使用自带的工具为VMware
中的Ubuntu Server
增大磁盘空间。 +Unsafe,一个“反 Java”的 class +/2023/12/25/Unsafe-an-anti-Java-class/ +Unsafe
类位于sun.misc
包中,它提供了一组用于执行低级别、不安全操作的方法。尽管Unsafe
类及其所有方法都是公共的,但它的使用受到限制,因为只有受信任的代码才能获取其实例。这个类通常被用于一些底层的、对性能敏感的操作,比如直接内存访问、CAS
(Compare and Swap
)操作等。本文将介绍这个“反Java
”的类及其方法的典型使用场景。--请注意辨别磁盘空间是真的接近耗尽,而不是在系统安装时只真正使用了大约一半空间。参见 Ubuntu server 20.04 安装后没有分配全部磁盘空间
+由于
Unsafe
类涉及到直接内存访问和其他底层操作,使用它需要极大的谨慎,因为它可以绕过Java
语言的一些安全性和健壮性检查。在正常的应用程序代码中,最好避免直接使用Unsafe
类,以确保代码的可读性和可维护性。在一些特殊情况下,比如一些高性能库的实现,可能会使用Unsafe
类来进行一些性能优化。背景介绍
环境如下:
--
-- VMware® Workstation 17 Pro 17.5.0 build-22583795
-- Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-169-generic x86_64)
-尽管最初按照心理预期为
-Ubuntu Server
分配了50G
的磁盘空间,主要用于运行一些Docker
容器,但是不知不觉之间发现磁盘空间的占用率还是上升到了90%
。一时之间想不到可以清理什么,决定先增大一些磁盘空间。-
-- 使用
-df -h
命令显示文件系统的总空间和可用空间信息。可知/dev/mapper/ubuntu--vg-ubuntu--lv
已使用95%
。
$ df -h
Filesystem Size Used Avail Use% Mounted on
udev 7.8G 0 7.8G 0% /dev
tmpfs 1.6G 3.0M 1.6G 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 48G 43G 2.5G 95% /
tmpfs 7.8G 0 7.8G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 7.8G 0 7.8G 0% /sys/fs/cgroup
vmhgfs-fuse 932G 859G 73G 93% /mnt/hgfs
/dev/sda2 2.0G 209M 1.6G 12% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/2015
/dev/loop1 64M 64M 0 100% /snap/core20/2105
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
/dev/loop3 92M 92M 0 100% /snap/lxd/24061
/dev/loop4 41M 41M 0 100% /snap/snapd/20671
tmpfs 1.6G 0 1.6G 0% /run/user/1000解决步骤
调整虚拟磁盘大小
-不论如何,需要先修改
+VMware
的相关设置。++尽管在生产中需要谨慎使用
+Unsafe
,但是可以在测试中使用它来更真实地接触Java
对象在内存中的存储结构,验证自己的理论知识。获取 Unsafe 实例
++在
Java 9
及之后的版本中,Unsafe
类中的getUnsafe()
方法被标记为不安全(Unsafe
),不再允许普通的Java
应用程序代码通过此方法获取Unsafe
实例。这是为了提高Java
的安全性,防止滥用Unsafe
类的功能。在正常的
+Java
应用程序中,获取Unsafe
实例是不被推荐的,因为它违反了Java
语言的安全性和封装原则。Unsafe
类的设计本意是为了Java
库和虚拟机的实现使用,而不是为了普通应用程序开发者使用。Unsafe
对象为调用者提供了执行不安全操作的能力,它可用于在任意内存地址读取和写入数据,因此返回的Unsafe
对象应由调用者仔细保护。它绝不能传递给不受信任的代码。此类中的大多数方法都是非常低级的,并且对应于少量硬件指令。获取
+Unsafe
实例的静态方法如下:+ +
public static Unsafe getUnsafe() {
Class<?> caller = Reflection.getCallerClass();
// 检查调用方法的类是被引导类加载器所加载
if (!VM.isSystemDomainLoader(caller.getClassLoader()))
throw new SecurityException("Unsafe");
return theUnsafe;
}
Unsafe
使用单例模式,可以通过静态方法getUnsafe
获取Unsafe
实例,并且调用方法的类为启动类加载器所加载才不会抛出异常。获取Unsafe
实例有以下两种可行方案:-
-- 先将客户机
-Ubuntu server
关机- 然后通过“虚拟机设置 -> 硬盘 -> 扩展 -> 最大磁盘大小”将最大虚拟磁盘大小设置为目标值(
-50G -> 80G
)- 根据提示可知,在
-VMware
中的扩展操作仅增大虚拟磁盘的大小,分区和文件系统的大小不受影响。你必须从客户机操作系统内部对磁盘重新进行分区和扩展文件系统。调整分区大小
分区管理
-
-- 使用
-sudo cfdisk
命令进入分区管理的交互式界面。可知可用空间为新增的30G
。- 使用上下方向键选择准备调整大小的分区
-/dev/sda3
,使用左右方向键选择Resize
操作。- 输入新的分区大小,默认为原大小加上可用空间大小等于
-78G
。- 使用左右方向键选择
-Write
操作,写入修改。然后输入yes
确认。- 提示分区表已改变。然后使用左右方向键选择
-Quit
操作,退出分区管理的交互式界面。- 退出时提示如下。
-
$ sudo cfdisk
GPT PMBR size mismatch (104857599 != 167772159) will be corrected by write.
Syncing disks.调整物理卷大小
-
-- 使用
-sudo pvresize /dev/sda3
命令调整LVM
中物理卷的大小。
$ sudo pvresize /dev/sda3
Physical volume "/dev/sda3" changed
1 physical volume(s) resized or updated / 0 physical volume(s) not resized调整逻辑卷大小
-
-- 使用
-sudo fdisk -l
命令显示物理卷和逻辑卷的大小差异。在末尾可见/dev/sda3
的大小为78G
,/dev/mapper/ubuntu--vg-ubuntu--lv
的大小为47.102G
。
$ sudo fdisk -l
Disk /dev/loop0: 63.48 MiB, 66547712 bytes, 129976 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop1: 63.93 MiB, 67014656 bytes, 130888 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop2: 40.88 MiB, 42840064 bytes, 83672 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop3: 91.85 MiB, 96292864 bytes, 188072 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop4: 40.44 MiB, 42393600 bytes, 82800 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/fd0: 1.42 MiB, 1474560 bytes, 2880 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x90909090
Device Boot Start End Sectors Size Id Type
/dev/fd0p1 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p2 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p3 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p4 2425393296 4850786591 2425393296 1.1T 90 unknown
Disk /dev/sda: 80 GiB, 85899345920 bytes, 167772160 sectors
Disk model: VMware Virtual S
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 81C6F71E-C634-49E6-BC3D-9272C86326A4
Device Start End Sectors Size Type
/dev/sda1 2048 4095 2048 1M BIOS boot
/dev/sda2 4096 4198399 4194304 2G Linux filesystem
/dev/sda3 4198400 167772126 163573727 78G Linux filesystem
Disk /dev/mapper/ubuntu--vg-ubuntu--lv: 47.102 GiB, 51535413248 bytes, 100655104 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes- 使用
+sudo lvresize -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
命令调整逻辑卷的大小。
$ sudo lvresize -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
Size of logical volume ubuntu-vg/ubuntu-lv changed from <48.00 GiB (12287 extents) to <78.00 GiB (19967 extents).
Logical volume ubuntu-vg/ubuntu-lv successfully resized.- 通过
+-Xbootclasspath/a:${path}
把调用方法的类所在的jar
包路径追加到启动类路径中,使该类被启动类加载器加载。关于启动类路径的信息可以参考Java 类加载器源码分析 | ClassLoader 的搜索路径- 通过反射获取
Unsafe
类中的Unsafe
实例
private static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}调整文件系统大小
-
- 使用
-sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
命令调整文件系统的大小。
$ sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
resize2fs 1.45.5 (07-Jan-2020)
Filesystem at /dev/mapper/ubuntu--vg-ubuntu--lv is mounted on /; on-line resizing required
old_desc_blocks = 6, new_desc_blocks = 10
The filesystem on /dev/mapper/ubuntu--vg-ubuntu--lv is now 20446208 (4k) blocks long.- 使用
+df -h
命令显示文件系统的总空间和可用空间信息。确认/dev/mapper/ubuntu--vg-ubuntu--lv
的大小已调整为77G
。
$ df -h
Filesystem Size Used Avail Use% Mounted on
udev 7.8G 0 7.8G 0% /dev
tmpfs 1.6G 3.1M 1.6G 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 77G 43G 31G 59% /
tmpfs 7.8G 0 7.8G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 7.8G 0 7.8G 0% /sys/fs/cgroup
vmhgfs-fuse 932G 859G 73G 93% /mnt/hgfs
/dev/sda2 2.0G 209M 1.6G 12% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/2015
/dev/loop1 64M 64M 0 100% /snap/core20/2105
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
/dev/loop3 92M 92M 0 100% /snap/lxd/24061
/dev/loop4 41M 41M 0 100% /snap/snapd/20671
tmpfs 1.6G 0 1.6G 0% /run/user/1000内存操作
+
Unsafe
类中包含了一些关于内存操作的方法,这些方法通常被认为是不安全的,因为它们可以绕过Java
语言的内置安全性和类型检查。以下是一些常见的Unsafe
类中关于内存操作的方法:+
+- +
allocateMemory
: 分配一个给定大小(以字节为单位)的本地内存块,内容未初始化,通常是垃圾。生成的本地指针永远不会为零,并且将针对所有类型进行对齐。
public native long allocateMemory(long bytes);- +
reallocateMemory
: 将本地内存块的大小调整为给定大小(以字节为单位),超过旧内存块大小的内容未初始化,通常是垃圾。当且仅当请求的大小为零时,生成的本地指针才为零。传递给此方法的地址可能为空,在这种情况下将执行分配。
public native long reallocateMemory(long address, long bytes);- +
freeMemory
: 释放之前由allocateMemory
或reallocateMemory
分配的内存。
public native void freeMemory(long address);- +
setMemory
: 将给定内存块中的所有字节设置为固定值(通常为零)。
public native void setMemory(Object o, long offset, long bytes, byte value);
public void setMemory(long address, long bytes, byte value) {
setMemory(null, address, bytes, value);
}- +
copyMemory
: 复制指定长度的内存块
public native void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes);- +
putXxx
: 将指定偏移量处的内存设置为指定的值,其中Xxx
可以是Object
、int
、long
、float
和double
等。
public native void putObject(Object o, long offset, Object x);- +
getXxx
: 从指定偏移量处的内存读取值,其中Xxx
可以是Object
、int
、long
、float
和double
等。
public native Object getObject(Object o, long offset);- +
putXxx
和getXxx
也提供了按绝对基地址操作内存的方法。
public native byte getByte(long address);
public native void putByte(long address, byte x);从内存读取值时,除非满足以下情况之一,否则结果不确定:
++
+- 偏移量是通过
+objectFieldOffset
从字段的Field
对象获取的,o
指向的对象的类与字段所属的类兼容。- 偏移量和
+o
指向的对象(无论是否为null
)分别是通过staticFieldOffset
和staticFieldBase
从Field
对象获得的。o
指向的是一个数组,偏移量是一个形式为B+N*S
的整数,其中N
是数组的有效索引,B
和S
分别是通过arrayBaseOffset
和arrayIndexScale
获得的值。++做一些“不确定”的测试,比如使用
+byte
相关的方法操作int
所在的内存块,是有意思且有帮助的,了解如何破坏,也可以更好地学习如何保护。分配堆外内存
在
+Java NIO
(New I/O
)中,分配堆外内存使用了Unsafe
类的allocateMemory
方法。堆外内存是一种在Java
虚拟机之外分配的内存,它不受Java
堆内存管理机制的控制。这种内存分配的主要目的是提高I/O
操作的性能,因为它可以直接与底层操作系统进行交互,而不涉及Java
堆内存的复杂性。Java 虚拟机的垃圾回收器虽然不直接管理这块内存,但是它通过一种称为“引用清理”(Reference Counting
)的机制来处理。+ +
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 分配本地内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 初始化本地内存
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 使用虚引用 Cleaner 对象跟踪 DirectByteBuffer 对象的垃圾回收
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}当
+DirectByteBuffer
对象仅被Cleaner
对象(虚引用)引用时,它可以在任意一次GC
中被垃圾回收。在DirectByteBuffer
对象被垃圾回收后,Cleaner
对象会被加入到引用队列,ReferenceHandler
线程将调用Deallocator
对象的run
方法,从而实现本地内存的自动释放。+ +
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 释放本地内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}CAS 相关
+
Unsafe
提供了3
个CAS
相关操作的方法,方法将内存位置的值与预期原值比较,如果相匹配,则CPU
会自动将该位置更新为新值,否则,CPU
不做任何操作。这些方法的底层实现对应着CPU
指令cmpxchg
。+ +
// 如果 Java 变量当前符合预期,则自动将其更新为 x。
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);在
+AtomicInteger
的实现中,静态字段valueOffset
即为字段value
的内存偏移地址,valueOffset
的值在AtomicInteger
初始化时,在静态代码块中通过Unsafe
的objectFieldOffset
方法获取。+ +
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
}CAS 更新变量的值的内存变化如下:
+ + +配合
+ClassLayout
打印AtomicInteger
的内部结构更直观地感受offset
的含义:+ + +
java.util.concurrent.atomic.AtomicInteger object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf8003dbc
12 4 int AtomicInteger.value 1
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total参考文章
]]>- linux -ubuntu +java - 使用 Vim -/2024/01/18/use-vim/ -本文记录了 Vim
常用的快捷键作为备忘清单。 +Java 类 Reference 的源码分析 +/2023/12/27/source-code-analysis-of-Java-class-Reference/ +我们知道 Java
扩充了“引用”的概念,引入了软引用、弱引用和虚引用,它们都属于Reference
类型,也都可以配合ReferenceQueue
使用。你是否好奇常常被一笔带过的“引用对象
的处理过程”?你是否在探究NIO
堆外内存的自动释放时看到了Cleaner
的关键代码但不太能梳理整个过程?你是否好奇在研究JVM
时偶尔看到的Reference Handler
线程?本文将分析Reference
和ReferenceQueue
的源码带你理解引用对象
的工作机制。 -常用快捷键
移动光标
- -
-- - -快捷键 -功能 -- -- h
,←
光标向左移动一个字符 -- -- j
,↓
光标向下移动一个字符 -- -- k
,↑
光标向上移动一个字符 -- -- l
,→
光标向右移动一个字符 -- -- Ctrl + f
,Page Down
屏幕向下移动一页 -- -- Ctrl + b
,Page Up
屏幕向上移动一页 -- -- 0
光标移动至本行开头 -- -- $
光标移动至本行末尾 -- -- G
光标移动至文件最后一行 -- -- nG
光标移动至文件第n行 -- -- gg
光标移动至文件第一行 -- -- n<Enter>
光标向下移动n行 -- -- n<space>
光标向右移动n个字符 -- -- ^
光标移动至本行第一个非空字符处 -- -- w
光标移动到下一个词 (上一个字母和数字组成的词之后) -- -- W
光标移动到下一个词 (以空格分隔的词) -- -- b
光标移动到上一个词 (下一个字母和数字组成的词之前) -- -- B
光标移动到上一个词 (以空格分隔的词) -查找和替换
- -
- - -快捷键 -功能 -- -- /word
向光标之后搜索word -- -- ?word
向光标之前搜索word -- -- n
重复前一个查找操作 -- -- N
反向进行前一个查找操作 -- -- :n1,n2s/original/replacement/g
在第n1行到第n2行之间查找original并替换为replacement -- -- :1,$s/original/replacement/g
在第1行到最后一行之间查找original并替换为replacement -- -- :1,$s/original/replacement/gc
在第1行到最后一行之间查找original并替换为replacement,替换前需确认 -- -- :%s/original/replacement
在所有行中查找行中第一个出现的original并替换为replacement ---替换格式如下
+:[range]s/<pattern>/[string]/[flags] [count]
事实上,个人感觉在无相关前置知识的情况下,单纯看
JDK
的Java
代码是没办法很好地理解引用对象
是如何被添加到引用队列
中的。因为Reference
的pending
字段的含义和赋值操作是隐藏在JVM
的C++
代码中,本文搁置了其中的细节,仅分析JDK
中相关的Java
代码。删除/复制/粘贴
- -
-- - -快捷键 -功能 -- -- x
向后删除一个字符,相当于 Del -- -- X
向前删除一个字符,相当于 Backspace -- -- nx
向前删除n个字符 -- -- dd
删除(剪切)光标所在的行 -- -- ndd
删除(剪切)光标所在开始的n行 -- -- d1G
删除(剪切)光标所在到第1行的所有行 -- -- dG
删除(剪切)光标所在到最后一行的所有行 -- -- d$
删除(剪切)光标所在到该行的最后一个字符 -- -- d0
删除(剪切)光标所在到该行的第一个字符 -- -- yy
复制光标所在的行 -- -- nyy
复制光标所在开始的n行 -- -- y1G
复制光标所在到第1行的所有行 -- -- yG
复制光标所在到最后一行的所有行 -- -- y$
复制光标所在到该行的最后一个字符 -- -- y0
复制光标所在到该行的第一个字符 -- -- p
将复制的内容粘贴到光标所在的下一行 -- -- P
将复制的内容粘贴到光标所在的上一行 -- -- u
恢复前一个操作 -- -- Ctrl+r
重做上一个操作 -- -- .
重复前一个操作 -进入编辑模式
- -
-- - -快捷键 -功能 -- -- i
进入插入模式,从光标所在处开始插入 -- -- I
进入插入模式,从光标所在行的第一个非空格开始插入 -- -- a
进入插入模式,从光标所在的下一个字符处开始插入 -- -- A
进入插入模式,从光标所在行的最后一个字符处开始插入 -- -- o
进入插入模式,在光标所在行的下一行插入新的一行 -- -- O
进入插入模式,在光标所在行的上一行插入新的一行 -- -- r
进入替换模式,只会替换光标所在的字符一次 -- -- R
进入替换模式,替换光标所在的字符,直到通过Esc退出 -- -- Esc
退出编辑模式,回到一般命令模式 -保存和退出
- -
-- - -快捷键 -功能 -- -- :w
保存文件 -- -- :w!
若文件为只读,强制保存 -- -- :q
退出 Vim,如果文件已修改,将退出失败 -- -- :q!
强制退出 Vim,不保存文件修改 -- -- :wq
保存文件并退出 Vim -- -- :w filename
另存为新文件 -- -- ZZ
退出 Vim,若文件无修改,则不保存退出;如果文件已修改,保存并退出 -- -- :r filename
读入另一个文件的数据并添加到光标所在行之后 -额外功能
可视模式
- -
-- - -快捷键 -功能 -- -- v
字符选择,将光标经过的地方反白选择 -- -- V
行选择,将光标经过的行反白选择 -- -- Ctrl + v
区块选择,用矩形的方式反白选择 -- -- y
复制反白选择的地方 -- -- d
删除反白选择的地方 -- -- ~
对反白选择的地方切换大小写 -多文件编辑
- -
-- - -快捷键 -功能 -- -- :n
编辑下一个文件 -- -- :N
编辑上一个文件 -- -- :files
列出当前 Vim 打开的所有文件 -多窗口功能
+
Reference
+ + +
Reference
是引用对象
的抽象基类。此类定义了所有引用对象通用的操作。由于引用对象是与垃圾收集器密切合作实现的,因此该类可能无法直接子类化。构造函数
+
+- +
referent
:引用对象
关联的对象- +
queue
:引用对象
准备注册到的引用队列
+
Reference
提供了两个构造函数,一个需要传入引用队列
(ReferenceQueue
),一个不需要。如果一个引用对象
(Reference
)注册到一个引用队列
,在检测到关联对象有适当的可达性变化后,垃圾收集器将把该引用对象
添加到该引用队列。++“关联对象有适当的可达性变化”并不容易理解,在很多表述中它很容易被简化为“可以被回收”,但是同时我们又拥有另一条规则,即“一个对象是否可回收的判断依据是是否从
+Root
对象可达”。在面对Reference
的子类时,我们有种割裂感,好像一条和谐的规则出现了特殊条例。探索 Java 类 Cleaner 和 Finalizer+ +
Reference(T referent) {
this(referent, null);
}
Reference(T referent, ReferenceQueue<? super T> queue) {
this.referent = referent;
// ReferenceQueue.NULL 表示没有注册到引用队列
this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}属性
成员变量
+
+ + +- +
referent
:引用对象
关联的对象,该对象将被垃圾收集器特殊对待。我们很难直观地感受何谓“被垃圾收集器特殊对待”,它对应着“在检测到关联对象有适当的可达性变化后,垃圾收集器将把引用对象
添加到该引用队列”。- +
queue
:引用对象
注册到的引用队列
- +
next
: 用于指向下一个引用对象
,当引用对象
已经添加到引用队列
中,next
指向引用队列
中的下一个引用对象
- +
discovered
: 用于指向下一个引用对象
,用于在全局的pending
链表中,指向下一个待添加到引用队列
的引用对象
静态变量
++注意:
+lock
和pending
是全局共享的。+
+- +
lock
: 用于与垃圾收集器同步的对象,垃圾收集器必须在每个收集周期开始时获取此锁。因此至关重要的是持有此锁的任何代码必须尽快运行完,不分配新对象并避免调用用户代码。- +
pending
: 等待加入引用队列
的引用对象
链表。垃圾收集器将引用对象
添加到pending
链表中,而Reference-Handler
线程将删除它们,并做清理或入队操作。pending
链表受上述lock
对象的保护,并使用discovered
字段来链接下一个元素。+ +
public abstract class Reference<T> {
private T referent; /* Treated specially by GC */
volatile ReferenceQueue<? super T> queue;
volatile Reference next;
transient private Reference<T> discovered; /* used by VM */
static private class Lock { }
private static Lock lock = new Lock();
private static Reference<Object> pending = null;
}+++
Reference
其实可以理解为单链表中的一个节点,除了核心的referent
和queue
,next
和discovered
都用于指向下一个引用对象
,只是分别用于两条不同的单链表上。+ + +
pending
链表:+ + +
ReferenceQueue
:ReferenceHandler 线程
启动任意一个非常简单的
+ + +Java
程序,通过JVM
相关的工具,比如JConsole
,你都能看到一个名为Reference Handler
的线程。+
ReferenceHandler
类本身的代码并不复杂。+ +
private static class ReferenceHandler extends Thread {
// 确保类已经初始化
private static void ensureClassInitialized(Class<?> clazz) {
try {
Class.forName(clazz.getName(), true, clazz.getClassLoader());
} catch (ClassNotFoundException e) {
throw (Error) new NoClassDefFoundError(e.getMessage()).initCause(e);
}
}
static {
// 预加载和初始化 InterruptedException 和 Cleaner,以避免在 run 方法中懒加载发生内存不足时陷入麻烦(咱也不知道具体啥麻烦)
ensureClassInitialized(InterruptedException.class);
ensureClassInitialized(Cleaner.class);
}
ReferenceHandler(ThreadGroup g, String name) {
super(g, name);
}
public void run() {
// run 方法循环调用 tryHandlePending
while (true) {
tryHandlePending(true);
}
}
}创建线程并启动
+
Reference-Handler
线程是通过静态代码块创建并启动的。+ +
static {
// 不断获取父线程组,直到最高的系统线程组
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
Thread handler = new ReferenceHandler(tg, "Reference Handler");
// 设置为最高优先级
handler.setPriority(Thread.MAX_PRIORITY);
// 设置为守护线程
handler.setDaemon(true);
handler.start();
// provide access in SharedSecrets
// 不懂,看到一个说法覆盖 JVM 的默认处理方式
SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
public boolean tryHandlePendingReference() {
return tryHandlePending(false);
}
});
}run 处理逻辑
+
run
方法的核心处理逻辑。本质上,ReferenceHandler
线程将pending
链表上的引用对象
分发到各自注册的引用队列
中。如果理解了Reference
作为单链表节点的一面,这部分代码不难理解,反而是其中应对OOME
的处理很值得关注,但更多的可能是看了个寂寞,不好重现问题并验证。+ +
static boolean tryHandlePending(boolean waitForNotify) {
Reference<Object> r;
Cleaner c;
try {
// 加锁(和垃圾回收共用一个锁)
synchronized (lock) {
// 如果不为 null
if (pending != null) {
// 获取头节点
r = pending;
// instanceof 可能抛出 OutOfMemoryError,因此在把 r 从 pending 链表中移除前进行
// 如果是 Cleaner 类型,进行类型转换,后续有特殊处理
c = r instanceof Cleaner ? (Cleaner) r : null;
// 从 pending 链表移除 r
pending = r.discovered;
r.discovered = null;
} else {
// 等待锁可能抛出 OutOfMemoryError,因为可能需要分配 exception 对象
if (waitForNotify) {
lock.wait();
}
// retry if waited
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
// 给其他线程 CPU 时间,以便它们能够丢弃一些存活的引用,然后通过 GC 回收一些空间
// 还可以防止 CPU 密集运行以至于上面的“r instanceof Cleaner”在一段时间内持续抛出 OOME
Thread.yield();
// retry
return true;
} catch (InterruptedException x) {
// retry
return true;
}
// 如果是 Cleaner 类型,快速清理并返回
if (c != null) {
c.clean();
return true;
}
// 如果 Reference 对象关联了引用队列,则添加到队列
ReferenceQueue<? super Object> q = r.queue;
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}关联对象和队列相关方法
+ +
/* -- Referent accessor and setters -- */
// 获取关联对象
public T get() {
return this.referent;
}
// 清理关联对象,该操作不会导致引用对象入队
public void clear() {
this.referent = null;
}
/* -- Queue operations -- */
// 判断引用对象是否已入队,如果未关联引用队列,则返回 false
public boolean isEnqueued() {
return (this.queue == ReferenceQueue.ENQUEUED);
}
// 将引用对象添加到其注册的引用队列中,该方法仅 Java 代码调用,JVM 不需要调用本方法可以直接进行入队操作(什么情况下?)
public boolean enqueue() {
return this.queue.enqueue(this);
}ReferenceQueue
+
引用队列
,在检测到适当的可达性更改后,垃圾收集器将已注册的引用对象
添加到该队列。属性
+ +
public class ReferenceQueue<T> {
// 构造函数
public ReferenceQueue() { }
// 一个不可入队的队列
private static class Null<S> extends ReferenceQueue<S> {
boolean enqueue(Reference<? extends S> r) {
return false;
}
}
// 用于表示一个引用对象没有注册到引用队列
static ReferenceQueue<Object> NULL = new Null<>();
// 用于表示一个引用对象已经添加到引用队列
static ReferenceQueue<Object> ENQUEUED = new Null<>();
// 锁对象
static private class Lock { };
private Lock lock = new Lock();
// 头节点
private volatile Reference<? extends T> head = null;
// 队列长度
private long queueLength = 0;
}入队
+
enqueue
只能由Reference
类调用。+
引用对象
的queue
字段可以表达引用对象
的状态:+
+- +
NULL
:表示没有注册到引用队列
或者已经从引用队列
中移除- +
ENQUEUED
:表示已经添加到引用队列
+ +
boolean enqueue(Reference<? extends T> r) {
synchronized (lock) {
// 检查引用对象的状态是否可以入队
ReferenceQueue<?> queue = r.queue;
if ((queue == NULL) || (queue == ENQUEUED)) {
return false;
}
// 检查注册的 queue 和调用的 queue 是否相同
assert queue == this;
// 标记为已入队
r.queue = ENQUEUED;
// 头插法,最后一个节点的 next 指向自身(为什么?)
r.next = (head == null) ? r : head;
head = r;
// 队列长度加一
queueLength++;
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(1);
}
// 通知等待的线程
lock.notifyAll();
return true;
}
}出队
轮询队列以查看是否有引用对象可用,如果存在可用的引用对象则将其从队列中删除并返回,否则该方法立即返回
+null
。+ +
public Reference<? extends T> poll() {
// 缩小锁的范围
if (head == null)
return null;
synchronized (lock) {
return reallyPoll();
}
}
private Reference<? extends T> reallyPoll() {
Reference<? extends T> r = head;
if (r != null) {
Reference<? extends T> rn = r.next;
// 因为尾节点的 next 指向自身
head = (rn == r) ? null : rn;
// 标记为 NULL,避免再次入队
r.queue = NULL;
// next 指向自己
r.next = r;
// 队列长度减一
queueLength--;
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(-1);
}
return r;
}
return null;
}出队操作提供了等待的选项。
++ +
// 从队列中移除下一个元素,阻塞直到有元素可用。
public Reference<? extends T> remove() throws InterruptedException {
return remove(0);
}
// 从队列中移除下一个元素,阻塞直到超时或有元素可用,timeout 以毫秒为单位。
public Reference<? extends T> remove(long timeout)
throws IllegalArgumentException, InterruptedException
{
if (timeout < 0) {
throw new IllegalArgumentException("Negative timeout value");
}
synchronized (lock) {
Reference<? extends T> r = reallyPoll();
if (r != null) return r;
long start = (timeout == 0) ? 0 : System.nanoTime();
for (;;) {
lock.wait(timeout);
r = reallyPoll();
if (r != null) return r;
// 如果 timeout 大于 0
if (timeout != 0) {
long end = System.nanoTime();
// 计算下一轮等待时间
timeout -= (end - start) / 1000_000;
// 到时间直接返回 null
if (timeout <= 0) return null;
// 更新开始时间
start = end;
}
}
}
}状态变化
+
Reference
实例(引用对象)可能处于四种内部状态之一:+
+- +
Active
: 新创建的实例处于Active
状态,受到垃圾收集器的特殊处理。收集器在检测到关联对象
的可达性变为适当状态后的一段时间,会将实例的状态更改为Pending
或Inactive
,具体取决于实例在创建时是否注册到引用队列
中。在前一种情况下,它还会将实例添加到待pending-Reference
列表中。- +
Pending
: 实例处在pending-Reference
列表中,等待Reference-Handler
线程将其加入引用队列
。未注册到引用队列
的实例永远不会处于这种状态。- +
Enqueued
: 处在创建实例时注册到的引用队列
中。当实例从引用队列中删除时,该实例将变为Inactive
状态。未注册到引用队列
的实例永远不会处于这种状态。- +
Inactive
: 没有进一步的操作。一旦实例变为Inactive
状态,其状态将永远不会再改变。+
Reference
实例(引用对象)的状态由queue
和next
字段共同表达:+
+ + +- +
Active
:(queue == ReferenceQueue || queue == ReferenceQueue.NULL) && next == null
- +
Pending
:queue == ReferenceQueue && next == this
- +
Enqueued
:queue == ReferenceQueue.ENQUEUED && (next == Following || this)
(在队列末尾时,next
指向自身,目前没有体现出这么设计的必要性啊?)- +
Inactive
:queue == ReferenceQueue.NULL && next == this
Reference 的子类
参考文章
+]]> ++ + +java ++ +探索 Java 类 Cleaner 和 Finalizer +/2023/12/28/explore-the-Java-classes-Cleaner-and-Finalizer/ ++ Java
类Cleaner
和Finalizer
都实现了一种finalization
机制,前者更轻量和强大,你可能在了解NIO
的堆外内存自动释放机制中注意过它;后者为人所诟病,finalize
方法被人强烈反对使用。本文想要解析它们的原因不在于它们实现的功能,而在于它们是Reference
的具体子类。Reference
作为和GC
紧密联系的类,你可能从很多文字描述中了解过SoftReference
、WeakReference
还有PhantomReference
但是却很少从代码层面了解过它们,当你牢记“一个对象是否可以被回收的判断依据是它是否从Root
对象可达”这条规则再面对Reference
的子类时是否产生过割裂感;你是否好奇过Finalizer
如何和重写finalize
方法的类产生联系,本文将从Cleaner
和Finalizer
的源码揭示一些你可能已知的结论背后的朴素原理。 + + +++本文的写作动机继承自 Java 类 Reference 的源码分析,有时候也会自我怀疑研究一个涉及大家极力劝阻使用的
+finalize
是否浪费精力,只能说确实如此!要不是半途而废会膈应难受肯定就停了!只能说这个过程确实帮助自己对Java
引用和GC
对其的处理有更加深刻的理解。虚引用之 Cleaner
虚引用介绍
+
PhantomReference
对象在垃圾收集器确定其关联对象
可以被回收时或可以被回收后一段时间,将被入队。“可以被回收”更明确的描述是“虚引用的关联对象
变成phantom reachable
,即只有虚引用引用了它”。但是和软引用和弱引用不同,当虚引用入队时并不会被垃圾收集器自动清理(其关联对象)。一个phantom reachable
的对象会一直维持原样直到所有虚引用被清理或者它们自身变得不可达。+
PhantomReference
的代码非常简单:+
+- +
PhantomReference
仅提供了一个public
构造函数,必须提供ReferenceQueue
参数。它不像SoftReference
和WeakReference
可以离开ReferenceQueue
单独使用,尽管queue
可以为null
,但是这样做并没有意义。- +
get()
返回null
,这意味着不能通过PhantomReference
获取其关联的对象referent
。+++
get()
返回null
并不是可以随意忽略的事情,它保证了phantom reachable
对象不会被重新触达和修改(这是为清理工作留出时间吗)。+ +
public class PhantomReference<T> extends Reference<T> {
public T get() {
return null;
}
public PhantomReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}通过以下示例验证
+GC
不会自动清理虚引用的关联对象:+ +
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
byte[] bytes = new byte[100 * 1024 * 1024];
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
PhantomReference<byte[]> phantomReference = new PhantomReference<>(bytes, queue);
Thread thread = new Thread(() -> {
for (; ; ) {
try {
Reference<? extends byte[]> remove = queue.remove(0);
System.out.println(remove + " enqueued");
// 需要调用 clear 主动清理关联对象,可以验证 gc 后总堆内存占用下降
// remove.clear();
// System.gc();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " interrupt");
break;
}
}
});
thread.start();
System.out.println("暂停查看堆内存占用");
scanner.next();
bytes = null;
System.gc();
System.out.println("gc 后 sleep 3s,查看总堆内存占用未下降");
TimeUnit.SECONDS.sleep(3);
scanner.next();
thread.interrupt();
}Cleaner 介绍
虚引用最常用于以比
+finalization
更灵活的方式安排清理工作,比如其子类Cleaner
就是一种基于虚引用的清理器,它比finalization
更轻量但更强大。Cleaner
追踪其关联对象
并封装任意的清理代码,在GC
检测到其关联对象
变成phantom reachable
后一段时间,Reference-Handler
线程将运行清理代码。同时Cleaner
可以被直接调用,它是线程安全的并且可以保证清理代码最多运行一次。但是Cleaner
不是finalization
的替代品,为了避免阻塞Reference-Handler
线程,清理代码应极其简单和直接。构造函数
+
Cleaner
的构造函数为private
,仅可通过create
方法创建实例。+
+- +
referent
:关联对象
- +
dummyQueue
: 假队列,需要它仅仅是因为PhantomReference
的构造函数需要一个queue
参数,但是这个queue
完全没用,在Reference
中Reference-Handler
线程会显式调用cleaners
而不会执行入队操作。+ +
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();
private final Runnable thunk;
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
// 添加到 Cleaner 自身维护的双链表
return add(new Cleaner(ob, thunk));
}
}添加
cleaner
+
+ + +- 使用
+synchronized
同步- +
Cleaner
自身维护一个双向链表存储cleaners
,通过静态变量first
存储头节点,以防止cleaners
比其关联对象
更早被GC
。+ +
// 头节点
static private Cleaner first = null;
// 双向指针
private Cleaner next = null, prev = null;
private static synchronized Cleaner add(Cleaner cl) {
// 头插法
if (first != null) {
cl.next = first;
first.prev = cl;
}
first = cl;
return cl;
}clean 方法
在
+Reference
中Reference-Handler
线程对于Cleaner
类型的对象,会显式地调用其clean
方法并返回,而不会将其入队。+
+- 使用
+synchronized
同步,从双链表上移除自身- 调用
+thunk
的run
方法+ +
public void clean() {
if (!remove(this))
return;
try {
thunk.run();
} catch (final Throwable x) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x).printStackTrace();
System.exit(1);
return null;
}
});
}
}
private static synchronized boolean remove(Cleaner cl) {
// next 指针指向自身代表已经移除,可以避免重复移除和执行
if (cl.next == cl)
return false;
// 更新双链表
if (first == cl) {
if (cl.next != null)
first = cl.next;
else
first = cl.prev;
}
if (cl.next != null)
cl.next.prev = cl.prev;
if (cl.prev != null)
cl.prev.next = cl.next;
// 通过将 next 指针指向自身表示已经被移除
cl.next = cl;
cl.prev = cl;
return true;
}Cleaner 处理流程
+
+- 创建的
+Cleaner
对象被Cleaner
类的双链表直接或间接引用(强引用),因此不会被垃圾回收- 一切的起点仍然是
+GC
特殊地对待虚引用的关联对象,当关联对象从reachable
变成phantom reachable
,GC
将Cleaner
对象将加入pending-list
- +
Reference-Handler
线程又将其移除并调用clean
方法- 在调用完毕后,
+Cleaner
对象变成unreachable
并最终被垃圾回收,其关联对象也被垃圾回收++ + +注意,Cleaner 对象本身在被调用完毕之前始终是被静态变量引用,是
+reachable
的,我们讨论的被判定为可回收的、变成phantom reachable
状态的是关联对象。++事实上,个人猜测“虚引用的关联对象不像软引用和弱引用会被自动清理”描述的仅仅是一个表象,判断是否要被垃圾回收的根本法则仍然是“对象是否从
+Root
对象可达”,软引用和弱引用的关联对象
之所以会被垃圾回收是因为它们在加入pending-list
时被从引用对象
断开,否则当引用对象
被添加到引用队列
时,引用队列
如果从Root
对象可达,将导致关联对象
也从Root
对象可达。在Reference
的clear()
的注释中提及该方法只被Java
代码调用,GC
不需要调用该方法就可以直接清理,肯定是GC
有直接清理关联对象
的场景。同时Reference
类有一句注释“GC
在检测到关联对象
有特定的可达性变化后,将把引用对象
添加到引用队列
”,它并未将特定的可达性变化直接描述为关联对象
变为不可达。目前尚未从JVM
源代码验证该猜测。终结引用之 Finalizer
+
FinalReference
用于实现finalization
,其代码很简单。+ +
class FinalReference<T> extends Reference<T> {
public FinalReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}其子类
+Finalizer
继承自FinalReference
,Cleaner
在代码设计上和它非常相似。构造函数
+
Finalizer
的构造函数为private
,仅可通过register
方法创建实例。+
+- +
finalizee
:关联对象
,即重写了finalize
方法的类的实例- +
queue
: 引用队列++根据注释
+register
由VM
调用,我们可以合理猜测,这里就是重写了finalize
方法的类的实例和Finalizer
对象关联的起点。+ +
final class Finalizer extends FinalReference<Object> {
private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
private Finalizer(Object finalizee) {
super(finalizee, queue);
add();
}
// 由 VM 调用
static void register(Object finalizee) {
new Finalizer(finalizee);
}
}添加
Finalizer
+
+ + +- 使用
+synchronized
同步- +
Finalizer
自身维护一个双向链表存储finalizers
,通过静态变量unfinalized
存储头节点+ +
private static Finalizer unfinalized = null;
private static final Object lock = new Object();
private Finalizer next = null, prev = null;
private void add() {
synchronized (lock) {
if (unfinalized != null) {
this.next = unfinalized;
unfinalized.prev = this;
}
unfinalized = this;
}
}
Finalizer
线程+ + +
finalizers
的清理通常是由一条名为Finalizer
的线程处理。启动任意一个非常简单的Java
程序,通过JVM
相关的工具,比如JConsole
,你都能看到一个名为Finalizer
的线程。run 方法
+ +
private static class FinalizerThread extends Thread {
private volatile boolean running;
FinalizerThread(ThreadGroup g) {
super(g, "Finalizer");
}
public void run() {
// 防止递归调用 run(什么场景?)
if (running)
return;
// Finalizer thread 在 System.initializeSystemClass 被调用前启动,等待 JavaLangAccess 可用
while (!VM.isBooted()) {
// 推迟直到 VM 初始化完成
try {
VM.awaitBooted();
} catch (InterruptedException x) {
// 忽略并继续
}
}
final JavaLangAccess jla = SharedSecrets.getJavaLangAccess();
// 标记为运行中
running = true;
for (;;) {
try {
// 从队列中移除
Finalizer f = (Finalizer)queue.remove();
// 调用 runFinalizer
f.runFinalizer(jla);
} catch (InterruptedException x) {
// 忽略并继续
}
}
}
}创建和启动
+
Finalizer
线程是通过静态代码块创建和启动的。+ +
static {
// 向上获取父线程组,直到系统线程组
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
// 创建 FinalizerThread 并启动
Thread finalizer = new FinalizerThread(tg);
// 设置优先级为最高减 2
finalizer.setPriority(Thread.MAX_PRIORITY - 2);
finalizer.setDaemon(true);
finalizer.start();
}获取 Finalizer 并调用
+ +
private void runFinalizer(JavaLangAccess jla) {
synchronized (this) {
// 判断是否已经终结过
if (hasBeenFinalized()) return;
// 从双链表上移除
remove();
}
try {
// 获取关联的 finalizee
Object finalizee = this.get();
// 如果不为 null 且不是 Enum 类型
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
// 调用 invokeFinalize
jla.invokeFinalize(finalizee);
// 清理栈槽以降低保守 GC 时误保留的可能性
finalizee = null;
}
} catch (Throwable x) { }
// 清理关联对象
super.clear();
}
// 和 Cleaner 类似,使用 next 指向自身表示已被移除
private boolean hasBeenFinalized() {
return (next == this);
}
// 和 Cleaner 类似的处理
private void remove() {
synchronized (lock) {
if (unfinalized == this) {
if (this.next != null) {
unfinalized = this.next;
} else {
unfinalized = this.prev;
}
}
if (this.next != null) {
this.next.prev = this.prev;
}
if (this.prev != null) {
this.prev.next = this.next;
}
this.next = this;
this.prev = this;
}
}finalize 的调用原理
关于如何调用
+finalize
方法涉及不少平时接触不到的代码。+ +
// 获取 JavaLangAccess
final JavaLangAccess jla = SharedSecrets.getJavaLangAccess();
// 通过 JavaLangAccess 调用 finalizee 的 finalize 方法
jla.invokeFinalize(finalizee);
public static void setJavaLangAccess(JavaLangAccess jla) {
javaLangAccess = jla;
}+
SharedSecrets
的javaLangAccess
通过setJavaLangAccess
设置+ +
public static void setJavaLangAccess(JavaLangAccess jla) {
javaLangAccess = jla;
}
public static JavaLangAccess getJavaLangAccess() {
return javaLangAccess;
}+
setJavaLangAccess
方法在System
中被调用,javaLangAccess
被设置为一个匿名类实例,其中invokeFinalize
方法间接调用了传入对象的finalize
方法。+ +
private static void setJavaLangAccess() {
// Allow privileged classes outside of java.lang
sun.misc.SharedSecrets.setJavaLangAccess(new sun.misc.JavaLangAccess(){
// ...
public void invokeFinalize(Object o) throws Throwable {
o.finalize();
}
});
}+
System
的setJavaLangAccess
方法在initializeSystemClass
方法中被调用。这里正对应着FinalizerThread
的run
方法中等待VM
初始化完成的处理。+ +
// 初始化 System class,在线程初始化之后调用
private static void initializeSystemClass() {
// ...
// register shared secrets
setJavaLangAccess();
// 通知 wait 的线程
sun.misc.VM.booted();
}Finalizer 的注册时机
你是否好奇过
+JVM
是如何保证finalize
方法最多被调用一次的?如果曾经猜测过JVM
可能在对象中留有标记,那么在我们研究过对象的内部结构之后可以确认其中并没有用于记录对象是否已经finalized
的地方。同时我们注意到hasBeenFinalized
方法通过next
指针是否指向自己表示是否已经finalized
。我们可以合理猜测register
的调用时机是在对象创建时,因此最多仅有一次被注册。通过以下示例可以测试:
++
+- 在创建重写了
+finalize
方法的类创建对象期间会调用register
创建并注册Finalizer
- 在未重写
+finalize
方法的类创建对象期间不会调用register
- +
Finalizer
不仅可以保证finalize
只会被调用一次,甚至不会第二次被添加到pending-list
,因为runFinalizer
最后调用了super.clear()
,JVM
不会特殊对待复活的对象+ + + + +
public class FinalReferenceTest_1 {
private static FinalizeObj save = null;
public static void main(String[] args) throws InterruptedException {
System.out.println("创建 finalize obj,使用 Debug 强制运行到 Finalizer.register");
FinalizeObj finalizeObj = new FinalizeObj();
System.out.println("gc");
finalizeObj = null;
System.gc();
System.out.println("sleep 1s");
TimeUnit.SECONDS.sleep(1);
save.echo();
save = null;
System.gc();
System.out.println("sleep 1s");
TimeUnit.SECONDS.sleep(1);
System.out.println(save == null);
}
static class FinalizeObj {
FinalizeObj() {
System.out.println("SaveSelf created");
}
protected void finalize() throws Throwable {
System.out.println("finalized");
save = this;
}
public void echo() {
System.out.println("I am alive.");
}
}
}参考文章
+]]>+ +java ++ +ComponentScan 扫描路径覆盖的真相 +/2023/12/11/the-truth-about-override-of-ComponentScan-basePackages/ ++ @ComponentScan
注解是Spring
中很常用的注解,用于扫描并加载指定类路径下的Bean
,而Spring Boot
为了便捷使用@SpringBootApplication
组合注解集成了@ComponentScan
的能力。也许你听说过使用后者会覆盖前者中关于包扫描的设置,但你是否质疑过这个“不合常理”的结论?是否好奇过为什么它们不像其他注解在嵌套使用时可以同时生效?又是否好奇过@SpringBootApplication
可以间接设置@ComponentScan
属性的原因?本文从源码角度分析@ComponentScan
的工作原理,揭示它独特的检索算法和注解层次结构中的属性覆盖机制。 + + ++
+- 本文的写作动机继承自Spring @Configuration 注解的源码分析,处理
+@ComponentScan
是处理@Configuration
过程的一部分。入口
对于标注了
+@ComponentScan
注解的配置类,处理过程如下:+
+- 获取
+@ComponentScan
的注解属性- 遍历注解属性集合,依次根据其中的信息进行扫描,获取
+Bean
定义- 如果获取到的
+Bean
定义中有任何其他配置类,将递归解析(处理配置类)++这里和处理
+@Import
的过程很像,都出现了递归解析新获得的配置类。+ +
protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass)
throws IOException {
// ...
// 处理任何 @ComponentScan 注解
// 获取 @ComponentScan 的注解属性,该注解是可重复的
Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(
sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class);
if (!componentScans.isEmpty() &&
!this.conditionEvaluator.shouldSkip(sourceClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN)) {
// 遍历
for (AnnotationAttributes componentScan : componentScans) {
// 如果配置类被标注了 @ComponentScan -> 立即扫描
Set<BeanDefinitionHolder> scannedBeanDefinitions =
this.componentScanParser.parse(componentScan, sourceClass.getMetadata().getClassName());
// 检测被扫描到的 Bean 定义中是否有任何其他配置类,如有需要递归解析
for (BeanDefinitionHolder holder : scannedBeanDefinitions) {
if (ConfigurationClassUtils.checkConfigurationClassCandidate(
holder.getBeanDefinition(), this.metadataReaderFactory)) {
// 递归解析配置类
parse(holder.getBeanDefinition().getBeanClassName(), holder.getBeanName());
}
}
}
}
// ...
}扫描获取 Bean 定义
我们先跳过“获取
+@ComponentScan
的注解属性”的过程,来看“扫描获取Bean
定义”的过程。扫描是通过ComponentScanAnnotationParser
的parse
方法完成的,这个方法很长,但逻辑并不复杂,主要是为ClassPathBeanDefinitionScanner
设置一些来自@ComponentScan
的注解属性值,最终执行扫描。ClassPathBeanDefinitionScanner
顾名思义是基于类路径的Bean
定义扫描器,真正的扫描工作全部委托给了它。在这些设置过程中,我们需要关注basePackages
的设置:+
+- 使用 Set 存储合并结果,用于去重
+- 获取设置的
+basePackages
值并添加- 获取设置的
+basePackageClasses
值,转换为它们所在的包名并添加- 如果结果集现在还是空的,获取被标注的配置类所在的包名并添加
+++最后一条规则就是“默认情况下扫描配置类所在的包”的说法由来,并且根据代码可知,如果主动设置了值,这条规则就不起作用了。
++ +
public Set<BeanDefinitionHolder> parse(AnnotationAttributes componentScan, final String declaringClass) {
Assert.state(this.environment != null, "Environment must not be null");
Assert.state(this.resourceLoader != null, "ResourceLoader must not be null");
// 创建 ClassPathBeanDefinitionScanner
ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(this.registry,
componentScan.getBoolean("useDefaultFilters"), this.environment, this.resourceLoader);
// Bean 名称生成器
Class<? extends BeanNameGenerator> generatorClass = componentScan.getClass("nameGenerator");
boolean useInheritedGenerator = (BeanNameGenerator.class == generatorClass);
scanner.setBeanNameGenerator(useInheritedGenerator ? this.beanNameGenerator :
BeanUtils.instantiateClass(generatorClass));
ScopedProxyMode scopedProxyMode = componentScan.getEnum("scopedProxy");
if (scopedProxyMode != ScopedProxyMode.DEFAULT) {
scanner.setScopedProxyMode(scopedProxyMode);
}
else {
Class<? extends ScopeMetadataResolver> resolverClass = componentScan.getClass("scopeResolver");
scanner.setScopeMetadataResolver(BeanUtils.instantiateClass(resolverClass));
}
scanner.setResourcePattern(componentScan.getString("resourcePattern"));
// 设置 Filter
for (AnnotationAttributes filter : componentScan.getAnnotationArray("includeFilters")) {
for (TypeFilter typeFilter : typeFiltersFor(filter)) {
scanner.addIncludeFilter(typeFilter);
}
}
for (AnnotationAttributes filter : componentScan.getAnnotationArray("excludeFilters")) {
for (TypeFilter typeFilter : typeFiltersFor(filter)) {
scanner.addExcludeFilter(typeFilter);
}
}
// 是否懒加载
boolean lazyInit = componentScan.getBoolean("lazyInit");
if (lazyInit) {
scanner.getBeanDefinitionDefaults().setLazyInit(true);
}
// 设置 basePackages(使用 Set 去重)
Set<String> basePackages = new LinkedHashSet<String>();
// 获取设置的 basePackages 值
String[] basePackagesArray = componentScan.getStringArray("basePackages");
for (String pkg : basePackagesArray) {
// 允许占位符
String[] tokenized = StringUtils.tokenizeToStringArray(this.environment.resolvePlaceholders(pkg),
ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
basePackages.addAll(Arrays.asList(tokenized));
}
// 获取 basePackageClasses,本质上是为了获取它们所在的包名
for (Class<?> clazz : componentScan.getClassArray("basePackageClasses")) {
basePackages.add(ClassUtils.getPackageName(clazz));
}
// 如果为空,获取被标注的配置类所在的包名
if (basePackages.isEmpty()) {
basePackages.add(ClassUtils.getPackageName(declaringClass));
}
// 排除被标注的配置类本身
scanner.addExcludeFilter(new AbstractTypeHierarchyTraversingFilter(false, false) {
protected boolean matchClassName(String className) {
return declaringClass.equals(className);
}
});
// 执行扫描
return scanner.doScan(StringUtils.toStringArray(basePackages));
}+
parse
方法与其说是解析,不如说是封装了一些设置并最终调用ClassPathBeanDefinitionScanner
,而设置的属性值来源于@ComponentScan
的注解属性。关于获取@ComponentScan
的注解属性的方法AnnotationConfigUtils.attributesForRepeatable
在分析@PropertySource
时也曾经遇到过,顾名思义我们知道它应该是用于获取可重复的注解的属性。可是它和直接获取注解对象有什么区别呢?++我们知道
+@SpringBootApplication
拥有和@ComponentScan
具备相似的功能,并且可以使用scanBasePackages
和scanBasePackageClasses
这两个属性设置扫描的包。也许你还知道@SpringBootApplication
之所以如此是因为它被标注了@ComponentScan
,scanBasePackages
和scanBasePackageClasses
分别是它的元注解@ComponentScan
中basePackages
和basePackageClasses
的别名。你甚至可能知道如果在配置类上使用@ComponentScan
设置包扫描后会导致@SpringBootApplication
设置的包扫描失效。
可是为什么呢?在Spring
中我们会看到从指定类上直接获取目标注解的代码,我们还会看到递归地从元注解上获取目标注解的代码,我们使用@ComponentScan
的经验告诉我们可重复注解不是覆盖彼此而是共同生效,那么为什么@SpringBootApplication
上的@ComponentScan
就被覆盖了呢?想当然的认为@SpringBootApplication
上标注了@ComponentScan
是一切的原因是不够的。+ +
public SpringBootApplication {
Class<?>[] exclude() default {};
String[] excludeName() default {};
String[] scanBasePackages() default {};
Class<?>[] scanBasePackageClasses() default {};
}获取注解属性
+
attributesForRepeatable
方法有两个重载方法,最终调用的版本如下。先后处理了@ComponentScan
和@ComponentScans
。+ +
static Set<AnnotationAttributes> attributesForRepeatable(AnnotationMetadata metadata,
String containerClassName, String annotationClassName) {
// Set 用于存储结果
Set<AnnotationAttributes> result = new LinkedHashSet<AnnotationAttributes>();
// 处理 @ComponentScan
addAttributesIfNotNull(result, metadata.getAnnotationAttributes(annotationClassName, false));
// 处理 @ComponentScans
Map<String, Object> container = metadata.getAnnotationAttributes(containerClassName, false);
if (container != null && container.containsKey("value")) {
for (Map<String, Object> containedAttributes : (Map<String, Object>[]) container.get("value")) {
addAttributesIfNotNull(result, containedAttributes);
}
}
return Collections.unmodifiableSet(result);
}检索注解的规则
根据注释,
+getAnnotationAttributes
方法检索给定类型的注解的属性,检索的目标可以是直接注解也可以是元注解,同时考虑组合注解上的属性覆盖。+
+- 元注解指的是标注在其他注解上的注解,用于对被标注的注解进行说明,比如
+@SpringBootApplication
上的@ComponentScan
就被称为元注解,此时@SpringBootApplication
被称为组合注解- 组合注解中存在属性覆盖现象
+++其实这两点分别对应了我们想要探究的两个问题:
+@ComponentScan
究竟是如何被检索的?注解属性比如basePackages
又是如何被覆盖的?+ +
public Map<String, Object> getAnnotationAttributes(String annotationName, boolean classValuesAsString) {
// 获取合并的注解属性
return (this.annotations.length > 0 ? AnnotatedElementUtils.getMergedAnnotationAttributes(
getIntrospectedClass(), annotationName, classValuesAsString, this.nestedAnnotationsAsMap) : null);
}根据注释,
+getMergedAnnotationAttributes
方法获取所提供元素上方的注解层次结构中指定的annotationName
的第一个注解,并将该注解的属性与注解层次结构较低级别中的注解中的匹配属性合并。注解层次结构中较低级别的属性会覆盖较高级别中的同名属性,并且完全支持单个注解中或是注解层次结构中的@AliasFor
语义。与getAllAnnotationAttributes
方法相反,一旦找到指定annotationName
的第一个注解,此方法使用的搜索算法将停止搜索注解层次结构。因此,指定的annotationName
的附加注解将被忽略。++这注释有点太抽象了,理解代码后再来回味吧。
++ +
public static AnnotationAttributes getMergedAnnotationAttributes(AnnotatedElement element,
String annotationName, boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
// 以 get 语义进行搜索(是指找到即终止搜索?)
AnnotationAttributes attributes = searchWithGetSemantics(element, null, annotationName,
new MergedAnnotationAttributesProcessor(classValuesAsString, nestedAnnotationsAsMap));
// 后处理注解属性
AnnotationUtils.postProcessAnnotationAttributes(element, attributes, classValuesAsString, nestedAnnotationsAsMap);
return attributes;
}+
searchWithGetSemantics
方法有多个重载方法,最终调用的版本如下:+
+- 先获取
+element
上的所有注解(包括重复的,不包括继承的),这意味着可重复注解@ComponentScan
标注了多个就会有多个实例- 在注解中搜索
+- 如果没找到,就从继承的注解中继续搜索
+++本方法是一个会被递归调用的方法,在第一次调用时
+element
是配置类,之后就是注解。+ +
private static <T> T searchWithGetSemantics(AnnotatedElement element,
Class<? extends Annotation> annotationType, String annotationName,
Class<? extends Annotation> containerType, Processor<T> processor,
Set<AnnotatedElement> visited, int metaDepth) {
// 防止无限递归
if (visited.add(element)) {
try {
// 获取 element 上的所有注解(包括重复,不包括继承的)
List<Annotation> declaredAnnotations = Arrays.asList(element.getDeclaredAnnotations());
// 在获得的注解中搜索
T result = searchWithGetSemanticsInAnnotations(element, declaredAnnotations,
annotationType, annotationName, containerType, processor, visited, metaDepth);
if (result != null) {
return result;
}
// 表明在直接声明的注解中没有找到
// 如果 element 是一个类
if (element instanceof Class) {
// 获取所有的注解(包括重复的和继承的)
List<Annotation> inheritedAnnotations = new ArrayList<>();
for (Annotation annotation : element.getAnnotations()) {
// 排除已经搜索过的,只留下继承的注解
if (!declaredAnnotations.contains(annotation)) {
inheritedAnnotations.add(annotation);
}
}
// 继续搜索
result = searchWithGetSemanticsInAnnotations(element, inheritedAnnotations,
annotationType, annotationName, containerType, processor, visited, metaDepth);
if (result != null) {
return result;
}
}
}
catch (Throwable ex) {
AnnotationUtils.handleIntrospectionFailure(element, ex);
}
}
return null;
}遍历注解进行搜索。
++
+- 先在注解中搜索,这意味着如果配置类标注了
+@ComponentScan
,直接就找到了- 如果没找到再在元注解中搜索,如果配置类只标注了
+@SpringBootApplication
,就是在这部分找到元注解@ComponentScan
++严格意义上说,并不是直接标注的
+@ComponentScan
会覆盖@SpringBootApplication
上间接标注的@ComponentScan
,而是搜索在找到第一个注解后终止没有继续查找。这解答了我们的第一个疑问。+ +
private static <T> T searchWithGetSemanticsInAnnotations( AnnotatedElement element,
List<Annotation> annotations, Class<? extends Annotation> annotationType,
String annotationName, Class<? extends Annotation> containerType,
Processor<T> processor, Set<AnnotatedElement> visited, int metaDepth) {
// 遍历注解进行查找,如果同时标注 @SpringBootApplication 和 @ComponentScan,在这部分就会找到 @ComponentScan 就返回了
for (Annotation annotation : annotations) {
// 获取注解的 Class
Class<? extends Annotation> currentAnnotationType = annotation.annotationType();
// 检测是否属于 Java 语言注解包中(以 java.lang.annotation 开头)的注解,例如 @Documented,是的话跳过
if (!AnnotationUtils.isInJavaLangAnnotationPackage(currentAnnotationType)) {
// 检测是否满足条件:等于 annotationType(传入 null),或者和目标的名字(@ComponentScan 全限定类名)相同,或者属于总是处理(默认 false)
if (currentAnnotationType == annotationType ||
currentAnnotationType.getName().equals(annotationName) ||
processor.alwaysProcesses()) {
// 处理注解获得注解属性
T result = processor.process(element, annotation, metaDepth);
if (result != null) {
// processor.aggregates() 默认返回 false
if (processor.aggregates() && metaDepth == 0) {
processor.getAggregatedResults().add(result);
}
else {
// 注意:难道标注多个 @ComponentScan 也只找到一个就返回了?
return result;
}
}
}
// 容器里的可重复注解,因为 containerType 为 null,跳过
else if (currentAnnotationType == containerType) {
for (Annotation contained : getRawAnnotationsFromContainer(element, annotation)) {
T result = processor.process(element, contained, metaDepth);
if (result != null) {
// No need to post-process since repeatable annotations within a
// container cannot be composed annotations.
processor.getAggregatedResults().add(result);
}
}
}
}
}
// 在元注解中递归的搜索,@SpringBootApplication 中的 @ComponentScan 就是在这找到的
for (Annotation annotation : annotations) {
// 获取注解的 Class
Class<? extends Annotation> currentAnnotationType = annotation.annotationType();
// 检测是否属于 Java 语言注解包中
if (!AnnotationUtils.isInJavaLangAnnotationPackage(currentAnnotationType)) {
// 递归到元注解中搜索,深度加 1
T result = searchWithGetSemantics(currentAnnotationType, annotationType,
annotationName, containerType, processor, visited, metaDepth + 1);
if (result != null) {
// 进行后处理,注解层次结构中较低级别的属性会覆盖较高级别中的同名属性就是在这发生的
processor.postProcess(element, annotation, result);
if (processor.aggregates() && metaDepth == 0) {
processor.getAggregatedResults().add(result);
}
else {
return result;
}
}
}
}
return null;
}处理
+@ComponentScan
获得AnnotationAttributes
。+ +
public AnnotationAttributes process(int metaDepth) { AnnotatedElement annotatedElement, Annotation annotation,
return AnnotationUtils.retrieveAnnotationAttributes(annotatedElement, annotation,
this.classValuesAsString, this.nestedAnnotationsAsMap);
}以
+AnnotationAttributes
映射的形式检索给定注解的属性。+ +
static AnnotationAttributes retrieveAnnotationAttributes( Object annotatedElement, Annotation annotation,
boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
Class<? extends Annotation> annotationType = annotation.annotationType();
AnnotationAttributes attributes = new AnnotationAttributes(annotationType);
// 遍历属性方法
for (Method method : getAttributeMethods(annotationType)) {
try {
// 获取属性值
Object attributeValue = method.invoke(annotation);
// 获取默认值
Object defaultValue = method.getDefaultValue();
// 如果默认值不为 null 且和属性值相同
if (defaultValue != null && ObjectUtils.nullSafeEquals(attributeValue, defaultValue)) {
attributeValue = new DefaultValueHolder(defaultValue);
}
// 属性名 -> 属性值
attributes.put(method.getName(),
adaptValue(annotatedElement, attributeValue, classValuesAsString, nestedAnnotationsAsMap));
}
catch (Throwable ex) {
if (ex instanceof InvocationTargetException) {
Throwable targetException = ((InvocationTargetException) ex).getTargetException();
rethrowAnnotationConfigurationException(targetException);
}
throw new IllegalStateException("Could not obtain annotation attribute value for " + method, ex);
}
}
return attributes;
}
// 获取在所提供的 annotationType 中声明的与 Java 对注释属性的要求相匹配的所有方法
static List<Method> getAttributeMethods(Class<? extends Annotation> annotationType) {
// 先从缓存中获取
List<Method> methods = attributeMethodsCache.get(annotationType);
if (methods != null) {
return methods;
}
// 遍历方法筛选
methods = new ArrayList<>();
for (Method method : annotationType.getDeclaredMethods()) {
if (isAttributeMethod(method)) {
ReflectionUtils.makeAccessible(method);
methods.add(method);
}
}
// 存入缓存
attributeMethodsCache.put(annotationType, methods);
return methods;
}
// 确定提供的方法是否是注解的属性方法。
static boolean isAttributeMethod( { Method method)
// 无参数 && 返回值非 void
return (method != null && method.getParameterCount() == 0 && method.getReturnType() != void.class);
}组合注解的属性覆盖
在获得注解属性后还要进行后处理,使用注解层次结构中较低级别的属性覆盖较高级别中的同名(包括
+@AliasFor
指定的)属性。比如使用@SpringBootApplication
中的scanBasePackages
的值覆盖@ComponentScan
中的basePackages
的值。+ +
public void postProcess( { AnnotatedElement element, Annotation annotation, AnnotationAttributes attributes)
annotation = AnnotationUtils.synthesizeAnnotation(annotation, element);
// 获取 AnnotationAttributes 的注解类型(@ComponentScan)
Class<? extends Annotation> targetAnnotationType = attributes.annotationType();
// Track which attribute values have already been replaced so that we can short
// circuit the search algorithms.
Set<String> valuesAlreadyReplaced = new HashSet<>();
// 获取注解的属性方法(SpringBootApplication)
for (Method attributeMethod : AnnotationUtils.getAttributeMethods(annotation.annotationType())) {
String attributeName = attributeMethod.getName();
// 获取被覆盖的别名
String attributeOverrideName = AnnotationUtils.getAttributeOverrideName(attributeMethod, targetAnnotationType);
// Explicit annotation attribute override declared via @AliasFor
if (attributeOverrideName != null) {
// 被覆盖的属性的值是否已经被替换
if (valuesAlreadyReplaced.contains(attributeOverrideName)) {
continue;
}
List<String> targetAttributeNames = new ArrayList<>();
targetAttributeNames.add(attributeOverrideName);
valuesAlreadyReplaced.add(attributeOverrideName);
// 确保覆盖目标注解中的所有别名属性。 (SPR-14069)
List<String> aliases = AnnotationUtils.getAttributeAliasMap(targetAnnotationType).get(attributeOverrideName);
if (aliases != null) {
for (String alias : aliases) {
if (!valuesAlreadyReplaced.contains(alias)) {
targetAttributeNames.add(alias);
valuesAlreadyReplaced.add(alias);
}
}
}
overrideAttributes(element, annotation, attributes, attributeName, targetAttributeNames);
}
// Implicit annotation attribute override based on convention
else if (!AnnotationUtils.VALUE.equals(attributeName) && attributes.containsKey(attributeName)) {
overrideAttribute(element, annotation, attributes, attributeName, attributeName);
}
}
}
// 根据提供的注解属性方法的 @AliasFor,获取被覆盖的属性的名称
static String getAttributeOverrideName(Method attribute, { Class<? extends Annotation> metaAnnotationType)
// 获取别名描述符
AliasDescriptor descriptor = AliasDescriptor.from(attribute);
// 从元注解中被覆盖的属性名
return (descriptor != null && metaAnnotationType != null ?
descriptor.getAttributeOverrideName(metaAnnotationType) : null);
}
// 获取在提供的注解类型中通过 @AliasFor 声明的所有属性别名的映射。该映射由属性名称作为键,每个值代表别名属性的名称列表。空返回值意味着注解没有声明任何属性别名。
static Map<String, List<String>> getAttributeAliasMap( { Class<? extends Annotation> annotationType)
if (annotationType == null) {
return Collections.emptyMap();
}
// 从缓存中获取
Map<String, List<String>> map = attributeAliasesCache.get(annotationType);
if (map != null) {
return map;
}
map = new LinkedHashMap<>();
// 遍历属性方法
for (Method attribute : getAttributeMethods(annotationType)) {
// 获取别名列表
List<String> aliasNames = getAttributeAliasNames(attribute);
if (!aliasNames.isEmpty()) {
map.put(attribute.getName(), aliasNames);
}
}
// 存入缓存
attributeAliasesCache.put(annotationType, map);
return map;
}
// 获取通过提供的注解属性的 @AliasFor 配置的别名属性的名称列表
static List<String> getAttributeAliasNames(Method attribute) {
AliasDescriptor descriptor = AliasDescriptor.from(attribute);
return (descriptor != null ? descriptor.getAttributeAliasNames() : Collections.<String> emptyList());
}
// 覆盖属性
private void overrideAttributes( AnnotatedElement element, Annotation annotation,
AnnotationAttributes attributes, String sourceAttributeName, List<String> targetAttributeNames) {
Object adaptedValue = getAdaptedValue(element, annotation, sourceAttributeName);
// 遍历目标属性中的所有应被覆盖的属性(本尊+别名)
for (String targetAttributeName : targetAttributeNames) {
attributes.put(targetAttributeName, adaptedValue);
}
}在代码的注释中我们留下过一个疑问,如果找到了第一个注解就立即返回,那么标注了多个
+@ComponentScan
呢?当你Debug
时,你会发现并没有走出现直接标注了@ComponentScan
的处理,其实看到反编译后的代码你就知道了,多个@ComponentScan
被合成了一个@ComponentScans
,甚至此时设置的三个basePackages
都是生效的。在JDK 8
引入的重复注解机制,并非一个语言层面上的改动,而是编译器层面的改动。在编译后,多个可重复注解@ComponentScan
会被合并到一个容器注解@ComponentScans
中。++因此,“
+@ComponentScan
的配置会覆盖@SpringBootApplication
关于包扫描的配置”这句话既对又不对,它在一个常见的个例上表现出的现象是对的,在更普遍的情况中以及本质上是错误的。你也许可以再根据一些情况罗列出类似的“@ComponentScan
使用规则”,但是如果你不明白背后的本质,那么这些只是一些死记硬背的陈述,甚至会带给你错误的认知。+ +
// 标注了两个 `@ComponentScan`,对编译后的字节码进行反编译
public class DemoApplication {
public DemoApplication() {
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}注解内的别名属性
+
postProcess
方法完成了组合注解的属性覆盖,可是对于@ComponentScan
注解而言,它没有被postProcess
方法处理,它又是如何做到设置basePackages
等于设置value
呢?其实这发生在后处理注解属性方法中,该方法会对注解中标注了@AliasFor
的属性强制执行别名语义。通俗地讲,就是统一或校验互为别名的属性值,要么只设置了其中一个属性的值,其他别名属性会被赋值为相同的值,要么设置为相同的值,否则会报错。+ +
public static AnnotationAttributes getMergedAnnotationAttributes(AnnotatedElement element,
String annotationName, boolean classValuesAsString, boolean nestedAnnotationsAsMap) {
// 以 get 语义进行搜索(是指找到即终止搜索?)
AnnotationAttributes attributes = searchWithGetSemantics(element, null, annotationName,
new MergedAnnotationAttributesProcessor(classValuesAsString, nestedAnnotationsAsMap));
// 后处理注解属性
AnnotationUtils.postProcessAnnotationAttributes(element, attributes, classValuesAsString, nestedAnnotationsAsMap);
return attributes;
}
static void postProcessAnnotationAttributes( Object annotatedElement,
boolean classValuesAsString, boolean nestedAnnotationsAsMap) { AnnotationAttributes attributes,
if (attributes == null) {
return;
}
// 获取 AnnotationAttributes 的注解类型(@ComponentScan)
Class<? extends Annotation> annotationType = attributes.annotationType();
// Track which attribute values have already been replaced so that we can short
// circuit the search algorithms.
Set<String> valuesAlreadyReplaced = new HashSet<>();
if (!attributes.validated) {
// 校验 @AliasFor 配置
// 获取别名映射
Map<String, List<String>> aliasMap = getAttributeAliasMap(annotationType);
// 遍历
for (String attributeName : aliasMap.keySet()) {
// 跳过已处理的
if (valuesAlreadyReplaced.contains(attributeName)) {
continue;
}
Object value = attributes.get(attributeName);
// 属性是否已有值
boolean valuePresent = (value != null && !(value instanceof DefaultValueHolder));
// 遍历属性的别名列表
for (String aliasedAttributeName : aliasMap.get(attributeName)) {
// 跳过已处理的
if (valuesAlreadyReplaced.contains(aliasedAttributeName)) {
continue;
}
// 获取别名属性的值
Object aliasedValue = attributes.get(aliasedAttributeName);
// 别名属性是否已有值
boolean aliasPresent = (aliasedValue != null && !(aliasedValue instanceof DefaultValueHolder));
// Something to validate or replace with an alias?
if (valuePresent || aliasPresent) {
// 如果属性已有值且别名属性也有值,校验是否相等
if (valuePresent && aliasPresent) {
// Since annotation attributes can be arrays, we must use ObjectUtils.nullSafeEquals().
if (!ObjectUtils.nullSafeEquals(value, aliasedValue)) {
String elementAsString =
(annotatedElement != null ? annotatedElement.toString() : "unknown element");
throw new AnnotationConfigurationException(String.format(
"In AnnotationAttributes for annotation [%s] declared on %s, " +
"attribute '%s' and its alias '%s' are declared with values of [%s] and [%s], " +
"but only one is permitted.", attributes.displayName, elementAsString,
attributeName, aliasedAttributeName, ObjectUtils.nullSafeToString(value),
ObjectUtils.nullSafeToString(aliasedValue)));
}
}
else if (aliasPresent) {
// 复制别名属性的值给属性
attributes.put(attributeName,
adaptValue(annotatedElement, aliasedValue, classValuesAsString, nestedAnnotationsAsMap));
valuesAlreadyReplaced.add(attributeName);
}
else {
// 复制属性的值给别名属性
attributes.put(aliasedAttributeName,
adaptValue(annotatedElement, value, classValuesAsString, nestedAnnotationsAsMap));
valuesAlreadyReplaced.add(aliasedAttributeName);
}
}
}
}
// 校验完毕
attributes.validated = true;
}
// 将 `value` 从 `DefaultValueHolder` 替换为原始的 `value`
for (String attributeName : attributes.keySet()) {
if (valuesAlreadyReplaced.contains(attributeName)) {
continue;
}
Object value = attributes.get(attributeName);
if (value instanceof DefaultValueHolder) {
value = ((DefaultValueHolder) value).defaultValue;
attributes.put(attributeName,
adaptValue(annotatedElement, value, classValuesAsString, nestedAnnotationsAsMap));
}
}
}总结
++又是一篇在写之前自认心里有数,以为可以很快总结完,却不知不觉写了很久,也收获了很多的文章。在刚开始,我只是想接续分析
+@Configuration
的思路补充关于@ComponentScan
的内容,但是渐渐地我又想要回应心里的疑问,@ComponentScan
和@SpringBootApplication
一起使用的问题的本质原因是什么?Spring
框架真的很好用,好用到你不用太关心背后的原理,好用到你有时候用一个本质上不太正确的结论“走遍天下却几乎不会遇到问题”。说实话,研究完也有点索然无味,尤其是花了这么多时间看自己很讨厌的关于解析的代码,只能说解开了一个卡点也算疏通了一口气,但是时间成本好大啊,得多看点能“面试”的技术啊!!!综上分析,
+@SpringBootApplication
的包扫描功能本质上还是@ComponentScan
提供的,但是和常见的嵌套注解不同,检索@ComponentScan
有一套独特的算法,导致@SpringBootApplication
和@ComponentScan
并非简单的叠加效果。+
+]]>- +
Spring
会先获取@ComponentScan
的注解属性再获取@ComponentScans
的注解属性- 以
+@ComponentScan
为例,只获取给定配置类上的注解层次结构中的第一个@ComponentScan
- 先从直接标注的注解开始,再递归地搜索元注解,这一点决定了
+@ComponentScan
优先级高于@SpringBootApplication
- 使用注解层次结构中较低级别的属性覆盖较高级别的同名(支持
+@AliasFor
)属性,这一点决定了@SpringBootApplication
可以设置扫描路径- 多个
+@ComponentScan
在编译后隐式生成@ComponentScans
,这一点决定多个@ComponentScan
彼此之间以及和@SpringBootApplication
互不冲突+ +java +spring +spring boot ++ -谈谈 MySQL 事务的隔离性 +/2024/01/06/talk-about-isolation-of-MySQL-transactions/ +事务就是一组数据库操作,它具有原子性( -Atomicity
)、一致性(Consistency
)、隔离性(Isolation
)和持久性(Durability
),简称为ACID
。本文将介绍MySQL
事务的隔离性以及对其的思考。
尽管这是一个老生常谈的话题,网上也有很多相关的资料,但是要理解它并不容易。即使林晓斌老师在 《MySQL 实战 45 讲》 中用了两个章节进行介绍,但是你在评论区中会发现有些分享或讨论的观点彼此矛盾。原因可能有很多,比如为了易于理解使用简化概念进行分析,有些具体细节各人各执一词同时它们又不好通过测试进行验证,用词不严谨等等。本文尽可能为自己梳理出一个完善并且前后一致的认知体系,再针对一些容易引起误解的地方作进一步的说明。 + + +隔离级别
+
SQL
标准的事务隔离级别包括:读未提交(read uncommitted
)、读提交(read committed
)、可重复读(repeatable read
)和串行化(serializable
)。当多个事务同时执行时,不同的隔离级别可能发生脏读(dirty read
)、不可重复读(non-repeatable read
)、幻读(phantom read
)等一个或多个现象。隔离级别越高,效率越低,因此很多时候,我们需要在二者之间寻找一个平衡点。-
- 快捷键 -功能 +隔离级别 +脏读 +不可重复读 +幻读 - -- :sp [filename]
打开一个新窗口 -- -- Ctrl + w
+j
Ctrl + w
+↓
光标移动到下方的窗口 -- -- Ctrl + w
+k
Ctrl + w
+↑
光标移动到上方的窗口 -- -- Ctrl + w
+q
:q
:close
关闭窗口 +读未提交 +Y +Y +Y 关键词自动补全
-
-- - -快捷键 -功能 -- - Ctrl + x
+Ctrl + n
使用当前文件的内容文字作为关键词,予以补齐 +读提交 +N +Y +Y - - Ctrl + x
+Ctrl + f
使用当前目录的文件名作为关键词,予以补齐 +可重复读 +N +N +Y - - Ctrl + x
+Ctrl + o
使用扩展名作为语法补充,以 Vim 内置的关键词,予以补齐 +串行化 +N +N +N 环境配置
+
+++读未提交和串行化很少在实际应用中使用。
+通过以下示例说明隔离级别的影响,
+V1
、V2
和V3
在不同隔离级别下的值有所不同。-
- 设置参数 -功能 +事务 A +事务 B +读未提交 +读提交 +可重复读 +串行化 - - :set nu
:set nonu
设置和取消行号 +开启事务 +开启事务 ++ + + - -- :syntax on
:syntax off
是否依据程序相关语法显示不同颜色 ---可以通过
-vim ~/.vimrc
修改配置文件。参考文章
-
-]]> -- vim 程式編輯器
-- Vim 快捷键速查表
-- Vim 配置入门
-- - -linux -vim -- k3s 的安装和使用 -/2024/01/30/installation-and-use-of-k3s/ -本文记录了 k3s
的安装和使用,相较于minikube
,前者是一个完全兼容的Kubernetes
发行版,安装和使用的体验更佳。 - - -安装
--参考官方文档-快速入门指南,使用默认选项启动集群非常简单方便!!!
-步骤
-
-- 获取并运行
-k3s
安装脚本。官方为中国用户提供了镜像加速支持。
$ curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn sh -
[sudo] password for moralok:
[INFO] Finding release for channel stable
[INFO] Using v1.28.5+k3s1 as release
[INFO] Downloading hash rancher-mirror.rancher.cn/k3s/v1.28.5-k3s1/sha256sum-amd64.txt
[INFO] Downloading binary rancher-mirror.rancher.cn/k3s/v1.28.5-k3s1/k3s
[INFO] Verifying binary download
[INFO] Installing k3s to /usr/local/bin/k3s
[INFO] Skipping installation of SELinux RPM
[INFO] Creating /usr/local/bin/kubectl symlink to k3s
[INFO] Creating /usr/local/bin/crictl symlink to k3s
[INFO] Skipping /usr/local/bin/ctr symlink to k3s, command exists in PATH at /usr/bin/ctr
[INFO] Creating killall script /usr/local/bin/k3s-killall.sh
[INFO] Creating uninstall script /usr/local/bin/k3s-uninstall.sh
[INFO] env: Creating environment file /etc/systemd/system/k3s.service.env
[INFO] systemd: Creating service file /etc/systemd/system/k3s.service
sh: 1014: restorecon: not found
sh: 1015: restorecon: not found
[INFO] systemd: Enabling k3s unit
Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service → /etc/systemd/system/k3s.service.
[INFO] systemd: Starting k3s- 可以通过使用
-kubectl
确认安装成功。刚安装的时候使用kubectl
需要root
权限。
$ sudo kubectl get node
NAME STATUS ROLES AGE VERSION
ubuntu-server Ready control-plane,master 52m v1.28.5+k3s1- 实际上安装的就是一个
-k3s
可执行文件,kubectl
和crictl
只是软链接,指向k3s
。
$ ls /usr/local/bin/
crictl k3s k3s-killall.sh k3s-uninstall.sh kubectl--安装的信息中显示了
-k3s
的service file
和environment file
的路径,后续修改启动参数和环境变量需要用到。配置文件权限问题
在刚安装完
-k3s
的时候,使用kubectl
需要root
权限。根据报错信息可知,是因为非root
用户无法读取配置文件/etc/rancher/k3s/k3s.yaml
。- -
$ kubectl get node
WARN[0000] Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode to modify kube config permissions
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied查看配置文件的信息可知其权限配置为
-600
,只有root
用户具有读写权限。- -
$ ll /etc/rancher/k3s/k3s.yaml
-rw------- 1 root root 2961 Jan 30 18:58 /etc/rancher/k3s/k3s.yaml一般来说,我们希望能够通过非
-root
用户使用kubectl
,避免通过root
用户或者通过sudo
加输入密码的形式来使用kubectl
。那么如何解决这个问题呢?本质上这是一个Linux
的文件权限问题,似乎修改文件的权限配置就可以解决。但是提示信息给出的解决方案并不是那么直接,它告诉我们通过修改k3s server
的启动参数来达到修改配置文件权限的目的。这是因为k3s
服务在每次重启时会根据启动参数和环境变量重置配置文件/etc/rancher/k3s/k3s.yaml
,手动修改文件的权限配置并不能优雅地解决这个问题,一旦服务重启,修改就会丢失。--k3s 的 Github Discussions 中讨论了这个问题,并链接了文档 管理 Kubeconfig 选项,文档介绍了通过修改启动参数和环境变量达到修改配置文件权限的目的。
-修改启动参数
第一种方式是修改启动参数。
--
-- -
sudo vim /etc/systemd/system/k3s.service
添加k3s
启动参数--write-kubeconfig-mode 644
ExecStart=/usr/local/bin/k3s \
server --write-kubeconfig-mode 644 \- -
systemctl daemon-reload
重新加载systemd
配置- -
systemctl restart k3s.service
重启服务- 验证修改生效
-
$ ll /etc/rancher/k3s/k3s.yaml
-rw-r--r-- 1 root root 2961 Jan 30 20:13 /etc/rancher/k3s/k3s.yaml修改环境变量
第二种方式是修改环境变量。
--
-- -
sudo vim /etc/systemd/system/k3s.service.env
添加环境变量K3S_KUBECONFIG_MODE=644
K3S_KUBECONFIG_MODE=644- -
systemctl restart k3s.service
重启服务修改配置文件路径
第三种方式是复制配置信息到当前用户目录下,并使用其作为配置文件的路径。
--
-- 设置环境变量
-export KUBECONFIG=~/.kube/config
- 创建文件夹
-mkdir ~/.kube 2> /dev/null
- 复制配置信息
-sudo k3s kubectl config view --raw > "$KUBECONFIG"
- 修改配置文件的权限
-chmod 600 "$KUBECONFIG"
配置代理
涉及
-k8s
,难免需要使用代理,否则在拉取镜像时将寸步难行。官方文档 配置 HTTP 代理 介绍了如何配置代理。其中提及k3s
安装脚本会自动使用当前shell
中的HTTP_PROXY
、HTTPS_PROXY
和NO_PROXY
,以及CONTAINERD_HTTP_PROXY
、CONTAINERD_HTTPS_PROXY
和CONTAINERD_NO_PROXY
变量(如果存在),并将它们写入systemd
服务的环境文件。比如我设置过shell
变量HTTP_PROXY
、HTTPS_PROXY
和NO_PROXY
,/etc/systemd/system/k3s.service.env
如下,你也可以自行编辑修改。- -
http_proxy='http://127.0.0.1:7890'
https_proxy='http://127.0.0.1:7890'
no_proxy='localhost,127.0.0.1'
K3S_KUBECONFIG_MODE=644使用
--k8s 基础教程可参考官方文档 Kubernetes 基础。
-创建 Deployment
-
-- 使用
-kubectl create
命令创建管理Pod
的Deployment
。该Pod
根据提供的Docker
镜像运行容器。
kubectl create deployment hello-node --image=registry.k8s.io/e2e-test-images/agnhost:2.39 -- /agnhost netexec --http-port=8080- 查看
-Deployment
:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
hello-node-ccf4b9788-d8k9b 1/1 Running 0 15h- 查看
-Pod
中容器的应用程序日志。
$ kubectl logs hello-node-ccf4b9788-d8k9b
I0130 19:26:57.751131 1 log.go:195] Started HTTP server on port 8080
I0130 19:26:57.751350 1 log.go:195] Started UDP server on port 8081创建 Service
-
-- 使用
-kubectl expose
命令将Pod
暴露给公网:
kubectl expose deployment hello-node --type=LoadBalancer --port=8080- 查看你创建的
-Service
:
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.43.0.1 <none> 443/TCP 15h
hello-node LoadBalancer 10.43.37.170 192.168.46.128 8080:32117/TCP 15h- 使用
-curl
发起请求:
$ curl http://localhost:8080
NOW: 2024-01-31 10:55:14.228709273 +0000 UTC m=+25932.159732511- 再次查看
-Pod
中容器的应用程序日志。
$ kubectl logs hello-node-ccf4b9788-d8k9b
I0130 19:26:57.751131 1 log.go:195] Started HTTP server on port 8080
I0130 19:26:57.751350 1 log.go:195] Started UDP server on port 8081
I0130 19:32:21.074992 1 log.go:195] GET /清理
-
-- 删除
-Service
:
kubectl delete service hello-node- 删除
-Deployment
:
kubectl delete deployment hello-node参考文章
-
- 快速入门指南
-- 管理 Kubeconfig 选项
-- 配置 HTTP 代理
-- 你好,Minikube
-- Permission denied on non-existing /etc/rancher/k3s/config.yaml after fresh install
-- error: error loading config file “/etc/rancher/k3s/k3s.yaml”: open /etc/rancher/k3s/k3s.yaml: permission denied
-- /etc/rancher/k3s/k3s.yaml is world readable #389
+查询得到值 1 ++ + + + + + + ++ 查询得到值 1 ++ + + + + ++ 修改值为 2 ++ + + + + +查询得到值 V1 ++ 2(读到B未提交的修改) +1 +1 +1 ++ ++ 提交事务 ++ + + + + +查询得到值 V2 ++ 2 +2(读到B已提交的修改) +1 +1 ++ +提交事务 ++ + + + + + +查询得到值 V3 ++ 2 +2 +2(A在事务期间数据一致) +1 ++ +补充说明 ++ + + + B的修改阻塞至A提交 +通过测试验证以上结论可以帮助你更直观地感受隔离级别的作用:
++
-]]>- 新建连接
+mysql –h localhost –u root -P 3306 –p
- 查看会话的事务隔离级别
+show variables like 'transaction_isolation';
- 设置会话的事务隔离级别
+set session transaction isolation level read uncommitted|read committed|repeatable read|serializable;
- 测试和验证
- -k8s -k3s -- -探索 Java 类 Cleaner 和 Finalizer -/2023/12/28/explore-the-Java-classes-Cleaner-and-Finalizer/ -- Java
类Cleaner
和Finalizer
都实现了一种finalization
机制,前者更轻量和强大,你可能在了解NIO
的堆外内存自动释放机制中注意过它;后者为人所诟病,finalize
方法被人强烈反对使用。本文想要解析它们的原因不在于它们实现的功能,而在于它们是Reference
的具体子类。Reference
作为和GC
紧密联系的类,你可能从很多文字描述中了解过SoftReference
、WeakReference
还有PhantomReference
但是却很少从代码层面了解过它们,当你牢记“一个对象是否可以被回收的判断依据是它是否从Root
对象可达”这条规则再面对Reference
的子类时是否产生过割裂感;你是否好奇过Finalizer
如何和重写finalize
方法的类产生联系,本文将从Cleaner
和Finalizer
的源码揭示一些你可能已知的结论背后的朴素原理。 - +
mysql> show variables like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+--本文的写作动机继承自 Java 类 Reference 的源码分析,有时候也会自我怀疑研究一个涉及大家极力劝阻使用的
+finalize
是否浪费精力,只能说确实如此!要不是半途而废会膈应难受肯定就停了!只能说这个过程确实帮助自己对Java
引用和GC
对其的处理有更加深刻的理解。
5.7
引入了transaction_isolation
作为tx_isolation
的别名,8.0.3
废弃后者。虚引用之 Cleaner
虚引用介绍
-
PhantomReference
对象在垃圾收集器确定其关联对象
可以被回收时或可以被回收后一段时间,将被入队。“可以被回收”更明确的描述是“虚引用的关联对象
变成phantom reachable
,即只有虚引用引用了它”。但是和软引用和弱引用不同,当虚引用入队时并不会被垃圾收集器自动清理(其关联对象)。一个phantom reachable
的对象会一直维持原样直到所有虚引用被清理或者它们自身变得不可达。-
PhantomReference
的代码非常简单:-
-- -
PhantomReference
仅提供了一个public
构造函数,必须提供ReferenceQueue
参数。它不像SoftReference
和WeakReference
可以离开ReferenceQueue
单独使用,尽管queue
可以为null
,但是这样做并没有意义。- -
get()
返回null
,这意味着不能通过PhantomReference
获取其关联的对象referent
。-+
get()
返回null
并不是可以随意忽略的事情,它保证了phantom reachable
对象不会被重新触达和修改(这是为清理工作留出时间吗)。了解数据库的隔离级别及其影响对于理解自身正在使用的数据库的行为、根据业务场景设置隔离级别优化性能以及迁移数据都是有帮助的。
+Oracle
数据库的默认隔离级别是“读提交”,MySQL
的默认隔离级别是“可重复读”。事务隔离的实现
+-在
MySQL
中,事务隔离是通过lock
、undo log
和read view
共同协作实现的。很多时候,我们关注 MVCC 在“读提交”和“可重复读”隔离级别中的作用而忽视事务隔离和锁的关系。- -
public class PhantomReference<T> extends Reference<T> {
public T get() {
return null;
}
public PhantomReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}通过以下示例验证
-GC
不会自动清理虚引用的关联对象:- -
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
byte[] bytes = new byte[100 * 1024 * 1024];
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
PhantomReference<byte[]> phantomReference = new PhantomReference<>(bytes, queue);
Thread thread = new Thread(() -> {
for (; ; ) {
try {
Reference<? extends byte[]> remove = queue.remove(0);
System.out.println(remove + " enqueued");
// 需要调用 clear 主动清理关联对象,可以验证 gc 后总堆内存占用下降
// remove.clear();
// System.gc();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " interrupt");
break;
}
}
});
thread.start();
System.out.println("暂停查看堆内存占用");
scanner.next();
bytes = null;
System.gc();
System.out.println("gc 后 sleep 3s,查看总堆内存占用未下降");
TimeUnit.SECONDS.sleep(3);
scanner.next();
thread.interrupt();
}Cleaner 介绍
虚引用最常用于以比
-finalization
更灵活的方式安排清理工作,比如其子类Cleaner
就是一种基于虚引用的清理器,它比finalization
更轻量但更强大。Cleaner
追踪其关联对象
并封装任意的清理代码,在GC
检测到其关联对象
变成phantom reachable
后一段时间,Reference-Handler
线程将运行清理代码。同时Cleaner
可以被直接调用,它是线程安全的并且可以保证清理代码最多运行一次。但是Cleaner
不是finalization
的替代品,为了避免阻塞Reference-Handler
线程,清理代码应极其简单和直接。构造函数
+
Cleaner
的构造函数为private
,仅可通过create
方法创建实例。
MySQL
各个事务隔离级别的实现原理简述如下:-
-- -
referent
:关联对象
- +
dummyQueue
: 假队列,需要它仅仅是因为PhantomReference
的构造函数需要一个queue
参数,但是这个queue
完全没用,在Reference
中Reference-Handler
线程会显式调用cleaners
而不会执行入队操作。- 串行化:读加共享锁,写加排他锁,读写互斥
+- 读未提交:写加排他锁,读不加锁
+- 可重复读:第一次读操作时创建快照,基于该快照进行读取
+- 读提交:每次读操作时重置快照,基于该快照进行读取
- -
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();
private final Runnable thunk;
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
// 添加到 Cleaner 自身维护的双链表
return add(new Cleaner(ob, thunk));
}
}添加
cleaner
-
- 使用
-synchronized
同步- +
Cleaner
自身维护一个双向链表存储cleaners
,通过静态变量first
存储头节点,以防止cleaners
比其关联对象
更早被GC
。前两者通过锁(
+lock
)实现比较容易理解;后两者通过多版本并发控制(MVCC
)实现。MVCC
是一种实现非阻塞并发读的设计思路,在InnoDB
引擎中主要通过undo log
和read view
实现。以下示意图表现了在
+InnoDB
引擎中,同一行数据存在多个“快照”版本,这就是数据库的多版本并发控制(MVCC
),当你基于快照读取时可以获得旧版本的数据。+
- + -- 假设一个值从 1 按顺序被修改为 2、3、4,最新值为 4。
+- 事务将基于各自拥有的“快照”读取数据而不受其他事务更新的影响,也不阻塞其他事务的更新。
+
// 头节点
static private Cleaner first = null;
// 双向指针
private Cleaner next = null, prev = null;
private static synchronized Cleaner add(Cleaner cl) {
// 头插法
if (first != null) {
cl.next = first;
first.prev = cl;
}
first = cl;
return cl;
}在接下来我们将通过锁、事务
+ID
、回滚日志和一致性视图逐步介绍InnoDB
事务隔离的实现原理。锁(lock)
事务在本质上是一个并发控制问题,而锁是解决并发问题的常见基础方案。
+MySQL
正是通过共享锁和排他锁实现串行化隔离级别。但是读加共享锁影响性能,尤其是在读写冲突频繁时,若不加发生“脏读”的缺陷又比较大,MVCC
就是用于在即使有读写冲突的情况下,不加读锁实现非阻塞并发读。++在
+InnoDB
的事务中,行锁(共享锁或排他锁)是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这个就是两阶段锁协议。理解两阶段锁协议,你会更深地体会读写冲突频繁时锁对性能的影响以及
+MVCC
的作用。长事务可能导致一个锁被长时间持有,导致拖垮整个库。事务 ID
在
+ -InnoDB
引擎中,每个事务都有唯一的一个事务ID
,叫做transaction id
。它是在事务开始的时候向InnoDB
的事务系统申请的,是按申请顺序严格递增的。同时每一行数据有一个隐藏字段trx_id
,记录了插入或更新该行数据的事务ID
。clean 方法
在
+Reference
中Reference-Handler
线程对于Cleaner
类型的对象,会显式地调用其clean
方法并返回,而不会将其入队。创建事务的时机
事务启动方式如下:
-
-- 使用
-synchronized
同步,从双链表上移除自身- 调用
+thunk
的run
方法- 显式启动事务语句是
+begin
或start transaction
,配套的提交语句是commit
,回滚语句是rollback
。- 隐式启动事务语句是
set autocommit = 0
,该设置将关闭自动提交。当你执行select
,将自动启动一个事务,直到你主动commit
或rollback
。- -
public void clean() {
if (!remove(this))
return;
try {
thunk.run();
} catch (final Throwable x) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x).printStackTrace();
System.exit(1);
return null;
}
});
}
}
private static synchronized boolean remove(Cleaner cl) {
// next 指针指向自身代表已经移除,可以避免重复移除和执行
if (cl.next == cl)
return false;
// 更新双链表
if (first == cl) {
if (cl.next != null)
first = cl.next;
else
first = cl.prev;
}
if (cl.next != null)
cl.next.prev = cl.prev;
if (cl.prev != null)
cl.prev.next = cl.next;
// 通过将 next 指针指向自身表示已经被移除
cl.next = cl;
cl.prev = cl;
return true;
}Cleaner 处理流程
-
-- 创建的
-Cleaner
对象被Cleaner
类的双链表直接或间接引用(强引用),因此不会被垃圾回收- 一切的起点仍然是
-GC
特殊地对待虚引用的关联对象,当关联对象从reachable
变成phantom reachable
,GC
将Cleaner
对象将加入pending-list
- -
Reference-Handler
线程又将其移除并调用clean
方法- 在调用完毕后,
-Cleaner
对象变成unreachable
并最终被垃圾回收,其关联对象也被垃圾回收-- +注意,Cleaner 对象本身在被调用完毕之前始终是被静态变量引用,是
-reachable
的,我们讨论的被判定为可回收的、变成phantom reachable
状态的是关联对象。但注意,实际上不论是显式启动事务情况下的
+begin
或start transaction
,还是隐式启动事务情况下的commit
或rollback
都不会立即创建一个新事务,而是直到第一次操作InnoDB
表的语句执行时,才会真正创建一个新事务。可以通过以下语句查看当前“活跃”的事务进行验证:
+
select * from information_schema.innodb_trx;--事实上,个人猜测“虚引用的关联对象不像软引用和弱引用会被自动清理”描述的仅仅是一个表象,判断是否要被垃圾回收的根本法则仍然是“对象是否从
+Root
对象可达”,软引用和弱引用的关联对象
之所以会被垃圾回收是因为它们在加入pending-list
时被从引用对象
断开,否则当引用对象
被添加到引用队列
时,引用队列
如果从Root
对象可达,将导致关联对象
也从Root
对象可达。在Reference
的clear()
的注释中提及该方法只被Java
代码调用,GC
不需要调用该方法就可以直接清理,肯定是GC
有直接清理关联对象
的场景。同时Reference
类有一句注释“GC
在检测到关联对象
有特定的可达性变化后,将把引用对象
添加到引用队列
”,它并未将特定的可达性变化直接描述为关联对象
变为不可达。目前尚未从JVM
源代码验证该猜测。只读事务的事务
ID
和更新事务不同。终结引用之 Finalizer
-
FinalReference
用于实现finalization
,其代码很简单。- -
class FinalReference<T> extends Reference<T> {
public FinalReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}其子类
-Finalizer
继承自FinalReference
,Cleaner
在代码设计上和它非常相似。构造函数
-
Finalizer
的构造函数为private
,仅可通过register
方法创建实例。-
- -
finalizee
:关联对象
,即重写了finalize
方法的类的实例- -
queue
: 引用队列--根据注释
+register
由VM
调用,我们可以合理猜测,这里就是重写了finalize
方法的类的实例和Finalizer
对象关联的起点。可以使用
commit work and chain;
在提交的同时开启下一次事务,减少一次begin;
指令的交互开销。- -
final class Finalizer extends FinalReference<Object> {
private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
private Finalizer(Object finalizee) {
super(finalizee, queue);
add();
}
// 由 VM 调用
static void register(Object finalizee) {
new Finalizer(finalizee);
}
}添加
Finalizer
-
- - -- 使用
-synchronized
同步- -
Finalizer
自身维护一个双向链表存储finalizers
,通过静态变量unfinalized
存储头节点- -
private static Finalizer unfinalized = null;
private static final Object lock = new Object();
private Finalizer next = null, prev = null;
private void add() {
synchronized (lock) {
if (unfinalized != null) {
this.next = unfinalized;
unfinalized.prev = this;
}
unfinalized = this;
}
}
Finalizer
线程- - -
finalizers
的清理通常是由一条名为Finalizer
的线程处理。启动任意一个非常简单的Java
程序,通过JVM
相关的工具,比如JConsole
,你都能看到一个名为Finalizer
的线程。run 方法
- -
private static class FinalizerThread extends Thread {
private volatile boolean running;
FinalizerThread(ThreadGroup g) {
super(g, "Finalizer");
}
public void run() {
// 防止递归调用 run(什么场景?)
if (running)
return;
// Finalizer thread 在 System.initializeSystemClass 被调用前启动,等待 JavaLangAccess 可用
while (!VM.isBooted()) {
// 推迟直到 VM 初始化完成
try {
VM.awaitBooted();
} catch (InterruptedException x) {
// 忽略并继续
}
}
final JavaLangAccess jla = SharedSecrets.getJavaLangAccess();
// 标记为运行中
running = true;
for (;;) {
try {
// 从队列中移除
Finalizer f = (Finalizer)queue.remove();
// 调用 runFinalizer
f.runFinalizer(jla);
} catch (InterruptedException x) {
// 忽略并继续
}
}
}
}创建和启动
-
Finalizer
线程是通过静态代码块创建和启动的。- -
static {
// 向上获取父线程组,直到系统线程组
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
// 创建 FinalizerThread 并启动
Thread finalizer = new FinalizerThread(tg);
// 设置优先级为最高减 2
finalizer.setPriority(Thread.MAX_PRIORITY - 2);
finalizer.setDaemon(true);
finalizer.start();
}获取 Finalizer 并调用
- -
private void runFinalizer(JavaLangAccess jla) {
synchronized (this) {
// 判断是否已经终结过
if (hasBeenFinalized()) return;
// 从双链表上移除
remove();
}
try {
// 获取关联的 finalizee
Object finalizee = this.get();
// 如果不为 null 且不是 Enum 类型
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
// 调用 invokeFinalize
jla.invokeFinalize(finalizee);
// 清理栈槽以降低保守 GC 时误保留的可能性
finalizee = null;
}
} catch (Throwable x) { }
// 清理关联对象
super.clear();
}
// 和 Cleaner 类似,使用 next 指向自身表示已被移除
private boolean hasBeenFinalized() {
return (next == this);
}
// 和 Cleaner 类似的处理
private void remove() {
synchronized (lock) {
if (unfinalized == this) {
if (this.next != null) {
unfinalized = this.next;
} else {
unfinalized = this.prev;
}
}
if (this.next != null) {
this.next.prev = this.prev;
}
if (this.prev != null) {
this.prev.next = this.next;
}
this.next = this;
this.prev = this;
}
}finalize 的调用原理
关于如何调用
-finalize
方法涉及不少平时接触不到的代码。- -
// 获取 JavaLangAccess
final JavaLangAccess jla = SharedSecrets.getJavaLangAccess();
// 通过 JavaLangAccess 调用 finalizee 的 finalize 方法
jla.invokeFinalize(finalizee);
public static void setJavaLangAccess(JavaLangAccess jla) {
javaLangAccess = jla;
}-
SharedSecrets
的javaLangAccess
通过setJavaLangAccess
设置- -
public static void setJavaLangAccess(JavaLangAccess jla) {
javaLangAccess = jla;
}
public static JavaLangAccess getJavaLangAccess() {
return javaLangAccess;
}-
setJavaLangAccess
方法在System
中被调用,javaLangAccess
被设置为一个匿名类实例,其中invokeFinalize
方法间接调用了传入对象的finalize
方法。- -
private static void setJavaLangAccess() {
// Allow privileged classes outside of java.lang
sun.misc.SharedSecrets.setJavaLangAccess(new sun.misc.JavaLangAccess(){
// ...
public void invokeFinalize(Object o) throws Throwable {
o.finalize();
}
});
}-
System
的setJavaLangAccess
方法在initializeSystemClass
方法中被调用。这里正对应着FinalizerThread
的run
方法中等待VM
初始化完成的处理。- -
// 初始化 System class,在线程初始化之后调用
private static void initializeSystemClass() {
// ...
// register shared secrets
setJavaLangAccess();
// 通知 wait 的线程
sun.misc.VM.booted();
}Finalizer 的注册时机
你是否好奇过
-JVM
是如何保证finalize
方法最多被调用一次的?如果曾经猜测过JVM
可能在对象中留有标记,那么在我们研究过对象的内部结构之后可以确认其中并没有用于记录对象是否已经finalized
的地方。同时我们注意到hasBeenFinalized
方法通过next
指针是否指向自己表示是否已经finalized
。我们可以合理猜测register
的调用时机是在对象创建时,因此最多仅有一次被注册。通过以下示例可以测试:
+回滚日志(undo log)
在
InnoDB
引擎中,每条记录在更新的时候都会同时记录一条回滚操作。记录的最新值,通过回滚操作,可以得到之前版本的值。它的作用是:-
-- 在创建重写了
-finalize
方法的类创建对象期间会调用register
创建并注册Finalizer
- 在未重写
-finalize
方法的类创建对象期间不会调用register
- +
Finalizer
不仅可以保证finalize
只会被调用一次,甚至不会第二次被添加到pending-list
,因为runFinalizer
最后调用了super.clear()
,JVM
不会特殊对待复活的对象- 数据回滚:当事务回滚或者数据库崩溃时,通过
+undolog
进行数据回滚。- 多版本并发控制:当读取一行记录时,如果该行记录已经被其他事务修改,通过
undo log
读取之前版本的数据,以此实现非阻塞并发读。- - - +
public class FinalReferenceTest_1 {
private static FinalizeObj save = null;
public static void main(String[] args) throws InterruptedException {
System.out.println("创建 finalize obj,使用 Debug 强制运行到 Finalizer.register");
FinalizeObj finalizeObj = new FinalizeObj();
System.out.println("gc");
finalizeObj = null;
System.gc();
System.out.println("sleep 1s");
TimeUnit.SECONDS.sleep(1);
save.echo();
save = null;
System.gc();
System.out.println("sleep 1s");
TimeUnit.SECONDS.sleep(1);
System.out.println(save == null);
}
static class FinalizeObj {
FinalizeObj() {
System.out.println("SaveSelf created");
}
protected void finalize() throws Throwable {
System.out.println("finalized");
save = this;
}
public void echo() {
System.out.println("I am alive.");
}
}
}实际上,每一行数据还有一个隐藏字段
+ -roll_ptr
。很多相关资料简单地描述“roll_ptr
用于指向该行数据的上一个版本”,但是该说法容易让人误解旧版本的数据是物理上真实存在的,好像有一张链表结构的历史记录表按顺序记录了每一个版本的数据。参考文章
-]]>- -java -- Java 类 Reference 的源码分析 -/2023/12/27/source-code-analysis-of-Java-class-Reference/ -我们知道 Java
扩充了“引用”的概念,引入了软引用、弱引用和虚引用,它们都属于Reference
类型,也都可以配合ReferenceQueue
使用。你是否好奇常常被一笔带过的“引用对象
的处理过程”?你是否在探究NIO
堆外内存的自动释放时看到了Cleaner
的关键代码但不太能梳理整个过程?你是否好奇在研究JVM
时偶尔看到的Reference Handler
线程?本文将分析Reference
和ReferenceQueue
的源码带你理解引用对象
的工作机制。 - +有些资料会特地强调旧版本的数据不是物理上真实存在的,
+undo log
是逻辑日志,记录了与实际操作语句相反的操作,旧版本的数据是通过undo log
计算得到的。--事实上,个人感觉在无相关前置知识的情况下,单纯看
+JDK
的Java
代码是没办法很好地理解引用对象
是如何被添加到引用队列
中的。因为Reference
的pending
字段的含义和赋值操作是隐藏在JVM
的C++
代码中,本文搁置了其中的细节,仅分析JDK
中相关的Java
代码。说实话,在不了解细节的前提下,通过计算得到旧版本的数据更加反直觉。总而言之,
InnoDB
的数据总是存储最新版本,尽管该版本所属的事务可能尚未提交;任何事务其实都是从最新版本开始回溯,直到获得该事务认为可见的版本。Reference
- - -
Reference
是引用对象
的抽象基类。此类定义了所有引用对象通用的操作。由于引用对象是与垃圾收集器密切合作实现的,因此该类可能无法直接子类化。构造函数
-
- -
referent
:引用对象
关联的对象- +
queue
:引用对象
准备注册到的引用队列
回滚日志的删除时机
回滚日志不会一直保留,在没有事务需要的时候,系统会自动判断和删除。基于该结论,我们应该避免使用长事务。长事务意味着系统里面可能会存在很老的
+read view
,这些事务可能访问数据库里的任何数据,所以在这个事务提交之前,数据库里它可能用到的回滚日志都必须保留,这就会导致大量存储空间被占用。在MySQL 5.5
及之前的版本中,回滚日志是和数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,但只是代表那部分存储空间可复用,文件并不会变小,需要重建整个库才能解决问题。一致性视图(read view)
一致性读视图(
+read view
)又可以称之为快照(snapshot
),它是基于整库的,但是它并不是真的拷贝了整个数据库的数据,否则随着数据量的增长,显然无法实现秒级创建快照。read view
可以理解为发出一个声明:“以我创建的时刻为准,如果一个数据版本所属的事务是在这之前提交的,就可见;如果是在这之后提交的,就不可见,需要回溯上一个版本判断,重复直到获得可见的版本;如果该数据版本属于当前事务自身,是可见的”。++以上声明类似于功能的需求描述,它比具体实现更简洁和易于理解。
+“快照”结合“多版本”等词,和
+undo log
的情况类似很容易让人误解为有一个物理上真实存在的数据快照,但实际上read view
只是在沿着数据版本链回溯时用于判断该版本对当前事务是否可见的依据。在具体实现上,InnoDB
为每一个事务构造了一个数组用于保存创建read view
时,当前正在“活跃”的所有事务ID
,其中“活跃”指的是启动了但尚未提交。数组中事务ID
的最小值记为低水位,当前系统里面已经创建过的事务ID
的最大值加 1 记为高水位。这个数组和高水位就组成了当前事务的一致性视图(read view
)。对于当前事务的read view
而言,一个数据版本的trx_id
,有以下几种可能:+
+- 如果小于低水位,表示这个版本是已提交的事务生成的,可见
+- 如果大于等于高水位,表示这个版本是创建
+read view
之后启动的事务,不可见- 如果大于等于低水位且小于高水位
++
-- 如果这个版本的
+trx_id
在数组中,表示这个版本是已启动但尚未提交的事务生成的,不可见- 如果这个版本的
trx_id
不在数组中,表示这个版本是已提交的事务生成的,可见+
Reference
提供了两个构造函数,一个需要传入引用队列
(ReferenceQueue
),一个不需要。如果一个引用对象
(Reference
)注册到一个引用队列
,在检测到关联对象有适当的可达性变化后,垃圾收集器将把该引用对象
添加到该引用队列。
InnoDB
利用“所有数据都有多个版本,每个版本都记录了所属事务ID
”这个特性,实现了“秒级创建快照”的能力。有了这个能力,系统里面随后发生的更新,就和当前事务可见的数据无关了,当前事务读取时也不必再加锁。--“关联对象有适当的可达性变化”并不容易理解,在很多表述中它很容易被简化为“可以被回收”,但是同时我们又拥有另一条规则,即“一个对象是否可回收的判断依据是是否从
+Root
对象可达”。在面对Reference
的子类时,我们有种割裂感,好像一条和谐的规则出现了特殊条例。探索 Java 类 Cleaner 和 Finalizer以上“具体实现”相较于之前的“需求描述”显得有些啰嗦和复杂,然而这里的细节是值得推敲的。即便是林晓斌老师在《MySQL 实战 45 讲》中的详细讲解也让部分读者包括我本人感到困惑。
+
Reference(T referent) {
this(referent, null);
}
Reference(T referent, ReferenceQueue<? super T> queue) {
this.referent = referent;
// ReferenceQueue.NULL 表示没有注册到引用队列
this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}林晓斌老师的数据版本可见性示意图如下,容易让人产生误解的地方在于三段式的划分给人一种已提交的事务全都是小于低水位的错觉。
+ -属性
成员变量
-
- +- -
referent
:引用对象
关联的对象,该对象将被垃圾收集器特殊对待。我们很难直观地感受何谓“被垃圾收集器特殊对待”,它对应着“在检测到关联对象有适当的可达性变化后,垃圾收集器将把引用对象
添加到该引用队列”。- -
queue
:引用对象
注册到的引用队列
- -
next
: 用于指向下一个引用对象
,当引用对象
已经添加到引用队列
中,next
指向引用队列
中的下一个引用对象
- -
discovered
: 用于指向下一个引用对象
,用于在全局的pending
链表中,指向下一个待添加到引用队列
的引用对象
事实上,已提交事务的分布可能如下,大部分人的疑问其实只是“在大于等于低水位小于高水位的范围中,为什么会有已提交的事务”。
+ -静态变量
-注意:
+lock
和pending
是全局共享的。要理解该问题需要理解另外一个问题——“创建
+read view
的时机”。创建 read view 的时机
很多资料介绍“可重复读”隔离级别下的
+read view
创建时机为在事务启动时,但这并不严谨,还会导致理解read view
数组困难。创建事务并不等于创建read view
。+官方文档:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTED isolation level, the snapshot is reset to the time of each consistent read operation.
-
-- -
lock
: 用于与垃圾收集器同步的对象,垃圾收集器必须在每个收集周期开始时获取此锁。因此至关重要的是持有此锁的任何代码必须尽快运行完,不分配新对象并避免调用用户代码。- +
pending
: 等待加入引用队列
的引用对象
链表。垃圾收集器将引用对象
添加到pending
链表中,而Reference-Handler
线程将删除它们,并做清理或入队操作。pending
链表受上述lock
对象的保护,并使用discovered
字段来链接下一个元素。- 对于“读提交”隔离级别,每次读操作都会重置快照。这意味着只要当前事务持续足够长的时间,它最后读取时完全可能熬到在它之前甚至之后创建的事务提交。
+- 对于“可重复读”隔离级别,在第一次执行快照读时创建快照。这意味着当前事务可以执行很多次以及很久的
update
语句后再执行读取,熬到在它之前甚至之后创建的事务提交。- +
public abstract class Reference<T> {
private T referent; /* Treated specially by GC */
volatile ReferenceQueue<? super T> queue;
volatile Reference next;
transient private Reference<T> discovered; /* used by VM */
static private class Lock { }
private static Lock lock = new Lock();
private static Reference<Object> pending = null;
}有些人可能想到了前者,但对于后者存疑或者不知道如何验证,其实测试并不复杂:
++ +
+ + +事务 A +事务 B ++ ++ begin;
+ begin;
+ ++ update t set k = 2 where id = 2;
(创建事务)+ + ++ + update t set k = 666 where id = 1;
(创建事务)+ ++ + commit;
+ ++ select * from t where id = 1;
(创建read view
,k = 666)+ + ++ commit;
+ --+
Reference
其实可以理解为单链表中的一个节点,除了核心的referent
和queue
,next
和discovered
都用于指向下一个引用对象
,只是分别用于两条不同的单链表上。因此,严谨地说,创建事务的时机和创建一致性视图的时机是不同的。通过
start transaction with consistent snapshot;
可以在开启事务的同时立即创建read view
。- - -
pending
链表:- - -
ReferenceQueue
:ReferenceHandler 线程
启动任意一个非常简单的
- - -Java
程序,通过JVM
相关的工具,比如JConsole
,你都能看到一个名为Reference Handler
的线程。-
ReferenceHandler
类本身的代码并不复杂。- -
private static class ReferenceHandler extends Thread {
// 确保类已经初始化
private static void ensureClassInitialized(Class<?> clazz) {
try {
Class.forName(clazz.getName(), true, clazz.getClassLoader());
} catch (ClassNotFoundException e) {
throw (Error) new NoClassDefFoundError(e.getMessage()).initCause(e);
}
}
static {
// 预加载和初始化 InterruptedException 和 Cleaner,以避免在 run 方法中懒加载发生内存不足时陷入麻烦(咱也不知道具体啥麻烦)
ensureClassInitialized(InterruptedException.class);
ensureClassInitialized(Cleaner.class);
}
ReferenceHandler(ThreadGroup g, String name) {
super(g, name);
}
public void run() {
// run 方法循环调用 tryHandlePending
while (true) {
tryHandlePending(true);
}
}
}创建线程并启动
-
Reference-Handler
线程是通过静态代码块创建并启动的。- -
static {
// 不断获取父线程组,直到最高的系统线程组
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
Thread handler = new ReferenceHandler(tg, "Reference Handler");
// 设置为最高优先级
handler.setPriority(Thread.MAX_PRIORITY);
// 设置为守护线程
handler.setDaemon(true);
handler.start();
// provide access in SharedSecrets
// 不懂,看到一个说法覆盖 JVM 的默认处理方式
SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
public boolean tryHandlePendingReference() {
return tryHandlePending(false);
}
});
}run 处理逻辑
-
run
方法的核心处理逻辑。本质上,ReferenceHandler
线程将pending
链表上的引用对象
分发到各自注册的引用队列
中。如果理解了Reference
作为单链表节点的一面,这部分代码不难理解,反而是其中应对OOME
的处理很值得关注,但更多的可能是看了个寂寞,不好重现问题并验证。- -
static boolean tryHandlePending(boolean waitForNotify) {
Reference<Object> r;
Cleaner c;
try {
// 加锁(和垃圾回收共用一个锁)
synchronized (lock) {
// 如果不为 null
if (pending != null) {
// 获取头节点
r = pending;
// instanceof 可能抛出 OutOfMemoryError,因此在把 r 从 pending 链表中移除前进行
// 如果是 Cleaner 类型,进行类型转换,后续有特殊处理
c = r instanceof Cleaner ? (Cleaner) r : null;
// 从 pending 链表移除 r
pending = r.discovered;
r.discovered = null;
} else {
// 等待锁可能抛出 OutOfMemoryError,因为可能需要分配 exception 对象
if (waitForNotify) {
lock.wait();
}
// retry if waited
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
// 给其他线程 CPU 时间,以便它们能够丢弃一些存活的引用,然后通过 GC 回收一些空间
// 还可以防止 CPU 密集运行以至于上面的“r instanceof Cleaner”在一段时间内持续抛出 OOME
Thread.yield();
// retry
return true;
} catch (InterruptedException x) {
// retry
return true;
}
// 如果是 Cleaner 类型,快速清理并返回
if (c != null) {
c.clean();
return true;
}
// 如果 Reference 对象关联了引用队列,则添加到队列
ReferenceQueue<? super Object> q = r.queue;
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}关联对象和队列相关方法
- -
/* -- Referent accessor and setters -- */
// 获取关联对象
public T get() {
return this.referent;
}
// 清理关联对象,该操作不会导致引用对象入队
public void clear() {
this.referent = null;
}
/* -- Queue operations -- */
// 判断引用对象是否已入队,如果未关联引用队列,则返回 false
public boolean isEnqueued() {
return (this.queue == ReferenceQueue.ENQUEUED);
}
// 将引用对象添加到其注册的引用队列中,该方法仅 Java 代码调用,JVM 不需要调用本方法可以直接进行入队操作(什么情况下?)
public boolean enqueue() {
return this.queue.enqueue(this);
}ReferenceQueue
-
引用队列
,在检测到适当的可达性更改后,垃圾收集器将已注册的引用对象
添加到该队列。属性
- -
public class ReferenceQueue<T> {
// 构造函数
public ReferenceQueue() { }
// 一个不可入队的队列
private static class Null<S> extends ReferenceQueue<S> {
boolean enqueue(Reference<? extends S> r) {
return false;
}
}
// 用于表示一个引用对象没有注册到引用队列
static ReferenceQueue<Object> NULL = new Null<>();
// 用于表示一个引用对象已经添加到引用队列
static ReferenceQueue<Object> ENQUEUED = new Null<>();
// 锁对象
static private class Lock { };
private Lock lock = new Lock();
// 头节点
private volatile Reference<? extends T> head = null;
// 队列长度
private long queueLength = 0;
}入队
-
enqueue
只能由Reference
类调用。+
引用对象
的queue
字段可以表达引用对象
的状态:当前读和快照读
现在我们知道在
+InnoDB
引擎中,一行数据存在多个版本。MVCC
使得在“可重复读”隔离级别下的事务好像与世无争。但是在以下示例中,事务 B 是在事务 A 的一致性视图之后创建和提交的,为什么事务 A 查询到的k
为 3 呢?+ +
++ + +事务 A +事务 B ++ ++ start transaction with consistent snapshot;
(k = 1)+ + ++ + update t set k = k + 1 where id = 1;
(自动提交事务)+ ++ update t set k = k + 1 where id = 1;
(当前读)+ + ++ select * from t where id = 1;
(k = 3)+ + ++ commit;
+ 其实,更新数据是先读后写的,并且是“当前读”。
-
-- -
NULL
:表示没有注册到引用队列
或者已经从引用队列
中移除- +
ENQUEUED
:表示已经添加到引用队列
- 当前读:读取一行数据的最新版本,并保证在读取时其他事务不能修改该行数据,因此需要在读取时加锁。以下操作属于当前读的情况:
++
-- 共享锁:
+select lock in share mode
- 排他锁:
select for update
,update
,insert
,delete
- -
boolean enqueue(Reference<? extends T> r) {
synchronized (lock) {
// 检查引用对象的状态是否可以入队
ReferenceQueue<?> queue = r.queue;
if ((queue == NULL) || (queue == ENQUEUED)) {
return false;
}
// 检查注册的 queue 和调用的 queue 是否相同
assert queue == this;
// 标记为已入队
r.queue = ENQUEUED;
// 头插法,最后一个节点的 next 指向自身(为什么?)
r.next = (head == null) ? r : head;
head = r;
// 队列长度加一
queueLength++;
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(1);
}
// 通知等待的线程
lock.notifyAll();
return true;
}
}出队
轮询队列以查看是否有引用对象可用,如果存在可用的引用对象则将其从队列中删除并返回,否则该方法立即返回
-null
。- -
public Reference<? extends T> poll() {
// 缩小锁的范围
if (head == null)
return null;
synchronized (lock) {
return reallyPoll();
}
}
private Reference<? extends T> reallyPoll() {
Reference<? extends T> r = head;
if (r != null) {
Reference<? extends T> rn = r.next;
// 因为尾节点的 next 指向自身
head = (rn == r) ? null : rn;
// 标记为 NULL,避免再次入队
r.queue = NULL;
// next 指向自己
r.next = r;
// 队列长度减一
queueLength--;
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(-1);
}
return r;
}
return null;
}出队操作提供了等待的选项。
-- -
// 从队列中移除下一个元素,阻塞直到有元素可用。
public Reference<? extends T> remove() throws InterruptedException {
return remove(0);
}
// 从队列中移除下一个元素,阻塞直到超时或有元素可用,timeout 以毫秒为单位。
public Reference<? extends T> remove(long timeout)
throws IllegalArgumentException, InterruptedException
{
if (timeout < 0) {
throw new IllegalArgumentException("Negative timeout value");
}
synchronized (lock) {
Reference<? extends T> r = reallyPoll();
if (r != null) return r;
long start = (timeout == 0) ? 0 : System.nanoTime();
for (;;) {
lock.wait(timeout);
r = reallyPoll();
if (r != null) return r;
// 如果 timeout 大于 0
if (timeout != 0) {
long end = System.nanoTime();
// 计算下一轮等待时间
timeout -= (end - start) / 1000_000;
// 到时间直接返回 null
if (timeout <= 0) return null;
// 更新开始时间
start = end;
}
}
}
}状态变化
-
Reference
实例(引用对象)可能处于四种内部状态之一:-
- -
Active
: 新创建的实例处于Active
状态,受到垃圾收集器的特殊处理。收集器在检测到关联对象
的可达性变为适当状态后的一段时间,会将实例的状态更改为Pending
或Inactive
,具体取决于实例在创建时是否注册到引用队列
中。在前一种情况下,它还会将实例添加到待pending-Reference
列表中。- -
Pending
: 实例处在pending-Reference
列表中,等待Reference-Handler
线程将其加入引用队列
。未注册到引用队列
的实例永远不会处于这种状态。- -
Enqueued
: 处在创建实例时注册到的引用队列
中。当实例从引用队列中删除时,该实例将变为Inactive
状态。未注册到引用队列
的实例永远不会处于这种状态。- +
Inactive
: 没有进一步的操作。一旦实例变为Inactive
状态,其状态将永远不会再改变。- 快照读:在不加锁的情况下通过
select
读取一行数据,但和“读未提交”隔离级别下单纯地读取最新版本不同,它是基于一个“快照”进行读取。+
Reference
实例(引用对象)的状态由queue
和next
字段共同表达:因此在事务 A 中更新时,读取到的是事务 B 更新后的最新值,在事务 A 更新后,依据
+read view
的可见性原则,它可以看到自身事务的更新后的最新值 3。如果事务 B 尚未提交的情况下,事务 A 发起更新,会如何呢?这时候就轮到“两阶段锁协议”派上用场了:
-
- - -- -
Active
:(queue == ReferenceQueue || queue == ReferenceQueue.NULL) && next == null
- -
Pending
:queue == ReferenceQueue && next == this
- -
Enqueued
:queue == ReferenceQueue.ENQUEUED && (next == Following || this)
(在队列末尾时,next
指向自身,目前没有体现出这么设计的必要性啊?)- +
Inactive
:queue == ReferenceQueue.NULL && next == this
- 事务 B 在更新时,对改行数据加排他锁,在事务 B 提交时才会释放
+- 当事务 A 发起更新,将阻塞直到事务 B 提交
Reference 的子类
参考文章
-
- 你不可不知的Java引用类型之——Reference源码解析
-- Java引用类型之:Reference源码解析
-- JVM之Reference源码分析
++ +
++ + +事务 A +事务 B ++ ++ start transaction with consistent snapshot;
(k = 1)+ + ++ + begin;
+ ++ + update t set k = k + 1 where id = 1;
(排他锁)+ ++ update t set k = k + 1 where id = 1;
(阻塞至 B 提交)+ + ++ + commit;
+ ++ select * from t where id = 1;
(k = 3)+ + ++ commit;
+ 至此,我们将锁和
+MVCC
在事务隔离的实现原理中串联起来了。两者是互相独立又互相协作的两个机制,前者实现了“当前读”,后者实现了“快照读”。总结
卡壳好几天,想到有不少好的文章却仍然会给读者留下困惑,想到自己在当初学习时对一些不严谨的表达抓耳挠腮想不通为什么,就有点不知道如何下笔。最终围绕着自己当初的一些困惑,一点一点修修补补完了。
+参考文章
]]>- java +mysql - synchronized 锁机制的分析和验证 -/2023/12/19/analysis-and-verification-of-the-synchronized-lock-mechanism/ -本文详细介绍了 Java
中synchronized
锁的机制、存储结构、优化措施以及升级过程,并通过jol-core
演示Mark Word
的变化来验证锁升级的多个case
。 +不使用 GParted 的情况下为 VMware 中的 Ubuntu Server 增大磁盘空间 +/2024/01/14/increase-disk-space-of-Ubuntu-server-on-VMware-without-using-GParted/ +GParted
是一款适用于Linux
的图形化磁盘分区管理工具,通过它可以便捷地为VMware
中的Ubuntu Desktop
增大磁盘空间。然而你可能正在使用Ubuntu Server
,并不想要安装或并不被允许安装图形化界面,本文介绍了如何在不使用GParted
的情况下,通过命令行使用自带的工具为VMware
中的Ubuntu Server
增大磁盘空间。--待完善
+请注意辨别磁盘空间是真的接近耗尽,而不是在系统安装时只真正使用了大约一半空间。参见 Ubuntu server 20.04 安装后没有分配全部磁盘空间
利用
+synchronized
实现同步的基础:Java
中的每一个对象都可以作为锁。具体表现为以下3
种形式。背景介绍
环境如下:
-
-- 对于普通同步方法,锁是当前实例对象。
-- 对于静态同步方法,锁是当前类的
-Class
对象。- 对于同步方法块,锁是
+synchronized
括号里配置的对象。- VMware® Workstation 17 Pro 17.5.0 build-22583795
+- Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-169-generic x86_64)
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
+尽管最初按照心理预期为
Ubuntu Server
分配了50G
的磁盘空间,主要用于运行一些Docker
容器,但是不知不觉之间发现磁盘空间的占用率还是上升到了90%
。一时之间想不到可以清理什么,决定先增大一些磁盘空间。-
-- 在
-JVM
层面,synchronized
锁是基于进入和退出Monitor
来实现的,每一个对象都有一个Monitor
与之相关联。- 在字节码层面,同步方法块是使用
+monitorenter
和monitorexit
指令实现的,前者在编译后插入到同步方法块的开始位置,后者插入到同步方法块的结束位置和异常位置。- 使用
df -h
命令显示文件系统的总空间和可用空间信息。可知/dev/mapper/ubuntu--vg-ubuntu--lv
已使用95%
。
$ df -h
Filesystem Size Used Avail Use% Mounted on
udev 7.8G 0 7.8G 0% /dev
tmpfs 1.6G 3.0M 1.6G 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 48G 43G 2.5G 95% /
tmpfs 7.8G 0 7.8G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 7.8G 0 7.8G 0% /sys/fs/cgroup
vmhgfs-fuse 932G 859G 73G 93% /mnt/hgfs
/dev/sda2 2.0G 209M 1.6G 12% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/2015
/dev/loop1 64M 64M 0 100% /snap/core20/2105
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
/dev/loop3 92M 92M 0 100% /snap/lxd/24061
/dev/loop4 41M 41M 0 100% /snap/snapd/20671
tmpfs 1.6G 0 1.6G 0% /run/user/1000存储结构
-锁存在哪里呢?锁里面又会存储什么信息呢?
+解决步骤
调整虚拟磁盘大小
+-不论如何,需要先修改
VMware
的相关设置。对象头
-
synchronized
用的锁是存在Java
对象头(object header
)里的。如果对象是数组类型,则虚拟机用3
字宽(Word
)存储对象头,如果对象是非数组类型,则用2
字宽存储对象头。在32
位虚拟机中,1
字宽等于4
字节,即32bit
。在64
位虚拟机中,1
字宽等于8
字节,即64bit
。-
Java
对象头的组成结构如下:+
+
+- 先将客户机
+Ubuntu server
关机- 然后通过“虚拟机设置 -> 硬盘 -> 扩展 -> 最大磁盘大小”将最大虚拟磁盘大小设置为目标值(
+50G -> 80G
)- 根据提示可知,在
+VMware
中的扩展操作仅增大虚拟磁盘的大小,分区和文件系统的大小不受影响。你必须从客户机操作系统内部对磁盘重新进行分区和扩展文件系统。调整分区大小
分区管理
+
+- 使用
+sudo cfdisk
命令进入分区管理的交互式界面。可知可用空间为新增的30G
。- 使用上下方向键选择准备调整大小的分区
+/dev/sda3
,使用左右方向键选择Resize
操作。- 输入新的分区大小,默认为原大小加上可用空间大小等于
+78G
。- 使用左右方向键选择
+Write
操作,写入修改。然后输入yes
确认。- 提示分区表已改变。然后使用左右方向键选择
+Quit
操作,退出分区管理的交互式界面。- 退出时提示如下。
+
$ sudo cfdisk
GPT PMBR size mismatch (104857599 != 167772159) will be corrected by write.
Syncing disks.调整物理卷大小
+
+- 使用
+sudo pvresize /dev/sda3
命令调整LVM
中物理卷的大小。
$ sudo pvresize /dev/sda3
Physical volume "/dev/sda3" changed
1 physical volume(s) resized or updated / 0 physical volume(s) not resized调整逻辑卷大小
+
+- 使用
+sudo fdisk -l
命令显示物理卷和逻辑卷的大小差异。在末尾可见/dev/sda3
的大小为78G
,/dev/mapper/ubuntu--vg-ubuntu--lv
的大小为47.102G
。
$ sudo fdisk -l
Disk /dev/loop0: 63.48 MiB, 66547712 bytes, 129976 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop1: 63.93 MiB, 67014656 bytes, 130888 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop2: 40.88 MiB, 42840064 bytes, 83672 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop3: 91.85 MiB, 96292864 bytes, 188072 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/loop4: 40.44 MiB, 42393600 bytes, 82800 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/fd0: 1.42 MiB, 1474560 bytes, 2880 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x90909090
Device Boot Start End Sectors Size Id Type
/dev/fd0p1 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p2 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p3 2425393296 4850786591 2425393296 1.1T 90 unknown
/dev/fd0p4 2425393296 4850786591 2425393296 1.1T 90 unknown
Disk /dev/sda: 80 GiB, 85899345920 bytes, 167772160 sectors
Disk model: VMware Virtual S
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 81C6F71E-C634-49E6-BC3D-9272C86326A4
Device Start End Sectors Size Type
/dev/sda1 2048 4095 2048 1M BIOS boot
/dev/sda2 4096 4198399 4194304 2G Linux filesystem
/dev/sda3 4198400 167772126 163573727 78G Linux filesystem
Disk /dev/mapper/ubuntu--vg-ubuntu--lv: 47.102 GiB, 51535413248 bytes, 100655104 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes- 使用
+sudo lvresize -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
命令调整逻辑卷的大小。
$ sudo lvresize -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
Size of logical volume ubuntu-vg/ubuntu-lv changed from <48.00 GiB (12287 extents) to <78.00 GiB (19967 extents).
Logical volume ubuntu-vg/ubuntu-lv successfully resized.调整文件系统大小
+
+- 使用
+sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
命令调整文件系统的大小。
$ sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
resize2fs 1.45.5 (07-Jan-2020)
Filesystem at /dev/mapper/ubuntu--vg-ubuntu--lv is mounted on /; on-line resizing required
old_desc_blocks = 6, new_desc_blocks = 10
The filesystem on /dev/mapper/ubuntu--vg-ubuntu--lv is now 20446208 (4k) blocks long.- 使用
+df -h
命令显示文件系统的总空间和可用空间信息。确认/dev/mapper/ubuntu--vg-ubuntu--lv
的大小已调整为77G
。
$ df -h
Filesystem Size Used Avail Use% Mounted on
udev 7.8G 0 7.8G 0% /dev
tmpfs 1.6G 3.1M 1.6G 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 77G 43G 31G 59% /
tmpfs 7.8G 0 7.8G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 7.8G 0 7.8G 0% /sys/fs/cgroup
vmhgfs-fuse 932G 859G 73G 93% /mnt/hgfs
/dev/sda2 2.0G 209M 1.6G 12% /boot
/dev/loop0 64M 64M 0 100% /snap/core20/2015
/dev/loop1 64M 64M 0 100% /snap/core20/2105
/dev/loop2 41M 41M 0 100% /snap/snapd/20290
/dev/loop3 92M 92M 0 100% /snap/lxd/24061
/dev/loop4 41M 41M 0 100% /snap/snapd/20671
tmpfs 1.6G 0 1.6G 0% /run/user/1000参考文章
+]]> ++ + +linux +ubuntu ++ 使用 Vim +/2024/01/18/use-vim/ +本文记录了 Vim
常用的快捷键作为备忘清单。 + + +常用快捷键
移动光标
+
- 长度 -内容 -说明 +快捷键 +功能 - - 32/64bit
- Mark Word
存储对象的 +hashCode
或锁信息+ h
,←
光标向左移动一个字符 - - 32/64bit
- Class Metadata Address
存储指向对象类型数据的指针 ++ j
,↓
光标向下移动一个字符 - +- 32/64bit
- Array length
数组的长度(如果当前对象是数组) ++ k
,↑
光标向上移动一个字符 ++ ++ l
,→
光标向右移动一个字符 ++ ++ Ctrl + f
,Page Down
屏幕向下移动一页 ++ ++ Ctrl + b
,Page Up
屏幕向上移动一页 ++ ++ 0
光标移动至本行开头 ++ ++ $
光标移动至本行末尾 ++ ++ G
光标移动至文件最后一行 ++ ++ nG
光标移动至文件第n行 ++ ++ gg
光标移动至文件第一行 ++ ++ n<Enter>
光标向下移动n行 ++ ++ n<space>
光标向右移动n个字符 ++ ++ ^
光标移动至本行第一个非空字符处 ++ ++ w
光标移动到下一个词 (上一个字母和数字组成的词之后) ++ ++ W
光标移动到下一个词 (以空格分隔的词) ++ ++ b
光标移动到上一个词 (下一个字母和数字组成的词之前) ++ ++ B
光标移动到上一个词 (以空格分隔的词) +查找和替换
+ +
-+ + +快捷键 +功能 ++ ++ /word
向光标之后搜索word ++ ++ ?word
向光标之前搜索word ++ ++ n
重复前一个查找操作 ++ ++ N
反向进行前一个查找操作 ++ ++ :n1,n2s/original/replacement/g
在第n1行到第n2行之间查找original并替换为replacement ++ ++ :1,$s/original/replacement/g
在第1行到最后一行之间查找original并替换为replacement ++ ++ :1,$s/original/replacement/gc
在第1行到最后一行之间查找original并替换为replacement,替换前需确认 ++ + :%s/original/replacement
在所有行中查找行中第一个出现的original并替换为replacement Mark Word
-
Java
对象头里的Mark Word
里默认存储对象的HashCode
,分代年龄和锁标记位。在运行期间,Mark Word
里存储的数据会随着锁标志位的变化而变化。Mark Word
可能变化为另外4
种数据。以
-32
位虚拟机为例:-
- -- -锁状态 -25bit -4bit -1bit -2bit -- -23bit -2bit -是否是偏向锁 -锁标志位 -- -无锁状态 -对象的 hashCode -对象分代年龄 -0 -01 -- -偏向锁 -线程 ID -Epoch -对象分代年龄 -1 -01 -- -轻量级锁 -指向栈中锁记录的指针 -00 -- -重量级锁 -指向互斥量(重量级锁)的指针 -10 -- -GC 标记 -空 -11 -以
-64
位虚拟机为例:-
- -- -锁状态 -56bit -1bit -4bit -1bit -2bit -- -25bit -31bit -- -- -是否是偏向锁 -锁标志位 -- -无锁状态 -unused -对象的 hashCode -cms_free -对象分代年龄 -0 -01 -- -偏向锁 -线程 ID(54bit) | Epoch(2bit) -cms_free -对象分代年龄 -1 -01 -- -轻量级锁 -指向栈中锁记录的指针 -00 -- -重量级锁 -指向互斥量(重量级锁)的指针 -10 -- -GC 标记 -空 -11 ---在上述表述中,很容易让人产生困惑的地方是
-hashCode
和分代年龄是对象的固有属性,当Mark Word
中存储的数据发生变化时,这些重要的数据去哪了?内部结构可视化
“百闻不如一见”,
-jol-core
提供了打印对象内部结构的能力。-
-- 添加依赖,新版本比旧版本打印结果的可读性更好
-
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>${org.openjdk.jol.version}</version>
</dependency>- 使用
-ClassLayout.parseInstance(objectExample).toPrintable()
打印
public class ObjectInternalTest {
private byte aByte;
private int aInt;
public static void main(String[] args) {
ObjectInternalTest objectInternalTest = new ObjectInternalTest();
log.info(ClassLayout.parseInstance(objectInternalTest).toPrintable());
}
}- 打印结果:
-mark|class|fields|alignment
。这样我们就能通过查看Mark Word
的值更直观地确定当前锁的状态。
2023-12-23 20:21:02 - com.moralok.concurrency.ch2.ObjectExample object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00060828
12 4 int ObjectExample.aInt 0
16 1 byte ObjectExample.aByte 0
17 7 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total指针压缩和 cms_free
注意到指向对象类型数据的指针仅
- -4
个字节,这是因为默认情况下JVM
参数UseCompressedOops
是启用的。- -
|--------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (96 bits) | State |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (32 bits) | |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record | lock:2 | OOP to metadata object | Lightweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor | lock:2 | OOP to metadata object | Heavyweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|使用
--XX:-UseCompressedOops
关闭指针压缩,指向对象类型数据的指针才会变回8
个字节- -
|------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (128 bits) | State |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (64 bits) | |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | OOP to metadata object | Lightweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | Heavyweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|------------------------------------------------------------------------------|-----------------------------|--------------------|你可能还会注意到开启和关闭指针压缩时,还有一个
bit
从cms_free
变成unused
。这个cms_free
是做什么用的呢?在未开启指针压缩的情况下,指针的低位因为内存对齐的缘故往往是0
,我们可以给这些bit
设置1
用于标记特殊状态。CMS
将Klass
指针的最低位设置为1
用于表示特定的内存块不是一个对象,而是空闲的内存。在开启指针压缩后,JVM
通过右移移除指针中没用到的低位,因此CMS
需要一个地方存储这个表示是否为空闲内存的bit
,就是cms_free
。--这在一定程度上解决了我心中的一个问题:
+JVM
是怎么判断一个空闲的内存块的?替换格式如下
:[range]s/<pattern>/[string]/[flags] [count]
concurrentMarkSweepGeneration.cpp
-- -
// A block of storage in the CMS generation is always in
// one of three states. A free block (FREE), an allocated
// object (OBJECT) whose size() method reports the correct size,
// and an intermediate state (TRANSIENT) in which its size cannot
// be accurately determined.
// STATE IDENTIFICATION: (32 bit and 64 bit w/o COOPS)
// -----------------------------------------------------
// FREE: klass_word & 1 == 1; mark_word holds block size
//
// OBJECT: klass_word installed; klass_word != 0 && klass_word & 1 == 0;
// obj->size() computes correct size
//
// TRANSIENT: klass_word == 0; size is indeterminate until we become an OBJECT
//
// STATE IDENTIFICATION: (64 bit+COOPS)
// ------------------------------------
// FREE: mark_word & CMS_FREE_BIT == 1; mark_word & ~CMS_FREE_BIT gives block_size
//
// OBJECT: klass_word installed; klass_word != 0;
// obj->size() computes correct size
//
// TRANSIENT: klass_word == 0; size is indeterminate until we become an OBJECT使用
-java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
开启HotSpot Debugger
,比对ClassLayout
打印的Klass
指针和Class Browser
中的指针。+
删除/复制/粘贴
+ +
++ + +快捷键 +功能 ++ ++ x
向后删除一个字符,相当于 Del ++ ++ X
向前删除一个字符,相当于 Backspace ++ ++ nx
向前删除n个字符 ++ ++ dd
删除(剪切)光标所在的行 ++ ++ ndd
删除(剪切)光标所在开始的n行 ++ ++ d1G
删除(剪切)光标所在到第1行的所有行 ++ ++ dG
删除(剪切)光标所在到最后一行的所有行 ++ ++ d$
删除(剪切)光标所在到该行的最后一个字符 ++ ++ d0
删除(剪切)光标所在到该行的第一个字符 ++ ++ yy
复制光标所在的行 ++ ++ nyy
复制光标所在开始的n行 ++ ++ y1G
复制光标所在到第1行的所有行 ++ ++ yG
复制光标所在到最后一行的所有行 ++ ++ y$
复制光标所在到该行的最后一个字符 ++ ++ y0
复制光标所在到该行的第一个字符 ++ ++ p
将复制的内容粘贴到光标所在的下一行 ++ ++ P
将复制的内容粘贴到光标所在的上一行 ++ ++ u
恢复前一个操作 ++ ++ Ctrl+r
重做上一个操作 ++ ++ .
重复前一个操作 +进入编辑模式
-
- 指针压缩 -关闭 -开启 +快捷键 +功能 - ClassLayout -0xf800c105 -0x00000245eb873d20 ++ i
进入插入模式,从光标所在处开始插入 - 二进制表达 -11111000000000001100000100000101 -00100100010111101011100001110011110100100000 ++ I
进入插入模式,从光标所在行的第一个非空格开始插入 - HotSpot Debugger -0x00000007c0060828 -0x00000245EB873D20 ++ a
进入插入模式,从光标所在的下一个字符处开始插入 - +二进制表达 -011111000000000001100000100000101000 -00100100010111101011100001110011110100100000 ++ A
进入插入模式,从光标所在行的最后一个字符处开始插入 ++ ++ o
进入插入模式,在光标所在行的下一行插入新的一行 ++ ++ O
进入插入模式,在光标所在行的上一行插入新的一行 ++ ++ r
进入替换模式,只会替换光标所在的字符一次 ++ ++ R
进入替换模式,替换光标所在的字符,直到通过Esc退出 ++ + Esc
退出编辑模式,回到一般命令模式 对象分代年龄
通过以下示例可以测试和验证对象分代年龄的变化。
-- -
public static void main(String[] args) throws InterruptedException {
log.info("测试 Mark Word 中的分代年龄");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态,age: 0");
log.info(ClassLayout.parseInstance(lock).toPrintable());
System.gc();
TimeUnit.SECONDS.sleep(1);
log.info("GC 后 =====> 无锁状态,age: 1");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}重量级锁
锁优化
-
Java 6
为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java 6
中,锁一共有4
种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,锁的状态会随着竞争的激化逐渐升级。锁状态可以升级但不能降级,举例来说偏向锁状态升级成轻量级锁状态后不能降级成偏向锁状态。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。--上述的表述并不容易理解,甚至容易让人产生误解。锁状态描述的是锁本身的状态,和是否处于加锁状态无关。以下列表格举例说明,一个偏向锁状态的对象,即使未加锁,也是偏向锁状态,而非无锁状态。
-+
保存和退出
-
- 层次 -未加锁 -加锁 +快捷键 +功能 - 1 -匿名偏向锁状态 or 偏向锁状态 -偏向锁状态 ++ :w
保存文件 - 2 -无锁状态 -轻量级锁状态 ++ :w!
若文件为只读,强制保存 - +3 -重要级锁状态 -重要级锁状态 ++ :q
退出 Vim,如果文件已修改,将退出失败 ++ ++ :q!
强制退出 Vim,不保存文件修改 ++ ++ :wq
保存文件并退出 Vim ++ ++ :w filename
另存为新文件 ++ ++ ZZ
退出 Vim,若文件无修改,则不保存退出;如果文件已修改,保存并退出 ++ + :r filename
读入另一个文件的数据并添加到光标所在行之后 --在查阅的众多资料中,关于锁升级过程的介绍并不详尽和准确,虽然大体上大家的观点是比较一致的,但是在一些细节的描述上却有些模糊不清,有些观点自相矛盾,有些观点互相矛盾,有些观点和我的知识或者测试结果矛盾,甚至有些逻辑不通顺以至于不能相互联系形成和谐的整体。以下内容尽可能结合相对权威和详细的资料,补充个人的思考和猜想作为缝合剂,并通过一些测试用例验证部分猜想,试图建立更加连续平滑以及可信服的知识面。
-锁升级变化图
提前放出锁升级变化图,用于在后续分析和测试过程中对照查看。重点关注以下可能引起锁状态变化的事件:
--
- - -- 获取锁和释放锁
-- 竞争,其中弱竞争是指线程交替进入同步块,没有发生直接冲突;强竞争是指线程在同步块内的时候有其他线程想要进入同步块
-- 调用特殊方法,比如计算
-hashCode
(非自定义)或者wait
方法偏向锁
-
HotSpot
的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁在
-Java 6
之后是默认开启的,可以通过JVM
参数-XX:-UseBiasedLocking
关闭偏向锁。尽管偏向锁是默认开启的,但是它在应用程序启动几秒钟之后才激活,延迟时间可以通过JVM
参数-XX:BiasedLockingStartupDelay
设置,默认情况下是4000ms
。测试偏向锁配置
延迟偏向
通过以下示例测试并验证延迟偏向。
-- -
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:偏向锁是延迟激活的");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
// 默认情况下偏向延迟的设置为 -XX:BiasedLockingStartupDelay=4000
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
log.info("偏向锁激活之后,新创建的对象的对象头的 Mark Word 是 =====> 匿名偏向锁");
Object biasedLock = new Object();
log.info(ClassLayout.parseInstance(biasedLock).toPrintable());
log.info("偏向锁激活之前创建的对象的对象头的 Mark Word 仍然是 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}测试结果如下:
-- -
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)-
-- -
JVM
启动后,偏向锁尚未激活前,创建的对象的Mark Word
的末尾3
位为0|01
,non-biasable
,表示无锁状态(非可偏向的)。- 在
-4000
毫秒后,新创建的对象的Mark Word
的末尾3
位为1|01
,biasable
,表示匿名偏向锁(可偏向的)。- 偏向锁尚未激活前创建的对象的对象头的
-Mark Word
的末尾3
位仍然是0|01
。--在虚拟机启动后,偏向锁激活前,创建的对象的锁标记位为
-1|01
,此时记录线程ID
的bit
全是0
(代表指向null
),没有偏向任何一个线程,该状态称之为匿名偏向锁。关闭偏向延迟
通过以下示例测试关闭偏向延迟:
-- -
// JVM 参数设置为 -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:关闭偏向锁的延迟偏向");
Object lock = new Object();
log.info("在虚拟机一启动,新创建的对象的对象头的 Mark Word 就是 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}关闭偏向锁
通过以下示例测试关闭偏向锁:
-- -
// JVM 参数设置为 -XX:-UseBiasedLocking
public static void main(String[] args) throws InterruptedException {
log.info("测试:关闭偏向锁");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("sleep 4000ms");
TimeUnit.MILLISECONDS.sleep(4000);
log.info("即使过了偏向延迟时间,创建的对象的对象头的 Mark Word 仍然是 =====> 无锁状态(非可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}《Java 并发编程的艺术》中写的是
--XX:-UseBiasedLocking=false
,测试中报错:- -
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
Improperly specified VM option 'UseBiasedLocking=false'另外书中说“在关闭偏向锁后程序默认会进入轻量级锁状态”,个人认为可能会让人产生误解,默认在未获取锁时为无锁状态,获取锁将变为轻量级锁状态。
-偏向锁加锁
当一个线程访问同步块时,先测试
-Mark Word
里是否存储着当前线程ID
:-
-- 如果否,则再测试
-Mark Word
中偏向锁的标识是否设置成1
-
-- 如果为
-0
,则说明不是偏向锁状态 =====> 获取偏向锁失败后续处理一- 如果为
-1
,则说明是偏向锁状态,通过CAS
操作设置偏向锁-
-- 如果成功,说明获得偏向锁
-- 如果失败,说明发生竞争 =====> 获取偏向锁失败后续处理二
-- 如果是,则说明当前线程就是之前获得偏向锁的线程,此刻再次获得锁
---在通过
-CAS
操作设置偏向锁中,Compare
操作是“测试Mark Word
存储线程ID
的bit
是否全部为0
,代表偏向的线程ID
为null
”,Swap
操作是将当前线程ID
设置到Mark Word
的相应位置。补充思考:
--
-- “通过
-CAS
操作将当前线程ID
设置到Mark Word
”在偏向锁状态下是有且仅有一次的“偏向”动作。(此观点存疑,在《Java 并发编程的艺术》一书中有“重新偏向于其他线程”这样的描述,但是关于竞争偏向锁部分的原理难以理解。个人在测试中,不论是持有偏向锁的线程仍存活但已离开同步块,还是已死亡,后续线程都无法再获取到偏向锁,唯一一种不同线程获取到同一个偏向锁的情况是两个线程可以复用同一个局部变量表槽位,它们的tid
相同,这代表着本质上Mark Word
并无变化)- 当获得偏向锁的线程离开同步块时,没有“解锁操作”,
-Mark Word
维持不变。个人也不知道如何更准确地描述这个现象,从synchronized
的语义来说,进出同步块代表着获取锁和释放锁;但是从偏向锁的实现来说,即便离开同步方法块,它仍然偏向原先获得锁的线程,甚至在讨论偏向锁发生竞争时,书中提到“检查持有偏向锁的线程是否存活”。个人更倾向于使用“撤销锁”一词描述偏向锁面临竞争时的处理,使用“释放锁”描述线程离开同步块时的处理。- 当获得偏向锁的线程再次访问同步块时,简单测试
-Mark Word
里存储着当前线程ID
,如果成功即可进入同步块。- 计算过
-hashCode
后偏向锁状态会变为其他状态,比如无锁状态,或者升级为轻量级锁甚至重量级锁,这符合CAS
操作的判断条件。偏向锁撤销
偏向锁使用了一种等到竞争出现才撤销的机制,当获得偏向锁的线程离开同步块时,并没有“解锁操作”,
-Mark Word
将维持不变。当竞争出现时,从现象上说,如果持有偏向锁的线程已经离开同步块,则锁升级为轻量级锁;如果持有锁的线程尚未离开同步块,则锁直接升级为重量级锁。--关于偏向锁的撤销,其原理晦涩难懂,个人仍有很多疑问:锁记录中存储偏向的线程
-ID
的作用,检查持有偏向锁的线程是否存活的作用不符合测试结果,重新偏向于其他线程的复现条件。因为理解有限,不多赘述。测试偏向锁升级
匿名偏向锁->偏向锁
--在一个匿名偏向锁状态的对象第一次被作为锁获取时,
-Mark Word
就会从匿名偏向锁变成偏向锁,并且再也不会变回到匿名偏向锁。测试在匿名偏向锁状态下获取锁将变成偏向锁状态:
-- -
public static void main(String[] args) throws IOException, InterruptedException {
log.info("偏向锁基础测试:匿名偏向锁 -> 偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("{} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,输入任意字符回车继续,可以使用 jstack 查看线程 tid 和 Mark Word 进行对比");
scanner.next();
}
log.info("偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 仍然不变");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}测试结果如下:
-- -
2023-12-21 00:34:39 - 偏向锁基础测试:匿名偏向锁 -> 偏向锁
2023-12-21 00:34:39 - sleep 4000ms,等待偏向锁激活
2023-12-21 00:34:43 - Mark Word 初始为 =====> 匿名偏向锁
2023-12-21 00:34:45 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2023-12-21 00:34:45 - main 获取锁 =====> 偏向锁
2023-12-21 00:34:45 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000028761af3005 (biased: 0x00000000a1d86bcc; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2023-12-21 00:34:45 - 暂停,输入任意字符回车继续,可以使用 jstack 查看线程 tid 和 Mark Word 进行对比
2023-12-21 00:34:55 - 偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 仍然不变
2023-12-21 00:34:55 - java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000028761af3005 (biased: 0x00000000a1d86bcc; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total通过
-jstack
获取线程tid
(以Windows
为例):- -
jps | findstr "BiasedLockingBaseTest" | ForEach-Object { jstack $_.Split()[0]} | findstr "main"
"main" #1 prio=5 os_prio=0 tid=0x0000028761af3000 nid=0x8668 waiting on condition [0x000000ff7b8ff000]
at com.moralok.concurrency.ch2.BiasedLockingBaseTest.main(BiasedLockingBaseTest.java:27)关注
-Mark Word
并转换为二进制表达:+
额外功能
可视模式
+
- - 二进制表达 +快捷键 +功能 - 匿名偏向锁 -Mark Word
00000000000000000000000000000000000000000101 ++ v
字符选择,将光标经过的地方反白选择 - 偏向锁状态 -Mark Word
00101000011101100001101011110011000000000101 ++ V
行选择,将光标经过的行反白选择 - - biased
00000000000010100001110110000110101111001100 ++ Ctrl + v
区块选择,用矩形的方式反白选择 - +- main
线程tid
00101000011101100001101011110011000000000000 ++ y
复制反白选择的地方 ++ ++ d
删除反白选择的地方 ++ ++ ~
对反白选择的地方切换大小写 +多文件编辑
+ +
++ + +快捷键 +功能 ++ ++ :n
编辑下一个文件 ++ ++ :N
编辑上一个文件 ++ ++ :files
列出当前 Vim 打开的所有文件 +多窗口功能
+ +
++ + +快捷键 +功能 ++ ++ :sp [filename]
打开一个新窗口 ++ ++ Ctrl + w
+j
Ctrl + w
+↓
光标移动到下方的窗口 ++ ++ Ctrl + w
+k
Ctrl + w
+↑
光标移动到上方的窗口 ++ ++ Ctrl + w
+q
:q
:close
关闭窗口 +关键词自动补全
+ +
++ + +快捷键 +功能 ++ ++ Ctrl + x
+Ctrl + n
使用当前文件的内容文字作为关键词,予以补齐 ++ ++ Ctrl + x
+Ctrl + f
使用当前目录的文件名作为关键词,予以补齐 ++ ++ Ctrl + x
+Ctrl + o
使用扩展名作为语法补充,以 Vim 内置的关键词,予以补齐 +环境配置
+ +
-+ + +设置参数 +功能 ++ ++ :set nu
:set nonu
设置和取消行号 ++ + :syntax on
:syntax off
是否依据程序相关语法显示不同颜色 -
-- 注意:存储的所谓“线程
-ID
”并非平时所说的线程ID
,该值左移可以得到jstack
的返回结果中的tid
,jol-core
打印了一个名为biased
的值与之相同- 在离开同步方法块后,
-Mark Word
不变偏向锁->轻量级锁
测试当拥有偏向锁的线程已经离开同步块,其他线程尝试获取偏向锁(弱竞争),锁将升级为轻量级锁。
-- -
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("测试:当持有偏向锁的线程已经离开同步块,其他线程尝试获取偏向锁时,将获得轻量级锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.SECONDS.sleep(4);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
Thread thread = new Thread(() -> {
log.info("第二个线程 {} 尝试获取锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("第二个线程 {} 获取锁 =====> 轻量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread.start();
thread.join();
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}有相关资料提到在拥有偏向锁的线程死亡后,锁可以偏向新的线程,但是验证失败。
-- -
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:之前获得偏向锁的线程已死时,新线程获得的仍然是偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread1.start();
Thread thread2 = new Thread(() -> {
try {
thread1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
boolean alive = thread1.isAlive();
log.info("第一个线程 {} 是否存活 {}", thread1.getName(), alive);
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("即使第一个线程已死亡,第二个线程 {} 获取锁 =====> 轻量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁->重量级锁
测试当拥有偏向锁的线程尚未离开同步块,其他线程尝试获取偏向锁(强竞争),锁将升级为重量级锁。
-- -
public static void main(String[] args) throws InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("测试:当持有偏向锁的线程尚未离开同步块,其他线程尝试获取偏向锁时,将升级为重量级锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.SECONDS.sleep(4);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,输入任意字符回车继续");
scanner.next();
log.info("第一个线程 {} 持有偏向锁,在同步块内发生竞争 =====> 升级为重量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("第一个线程 {} 结束", Thread.currentThread().getName());
}, "thread1");
thread1.start();
TimeUnit.SECONDS.sleep(1);
Thread thread2 = new Thread(() -> {
log.info("第二个线程 {} 尝试获取偏向锁失败", Thread.currentThread().getName());
synchronized (lock) {
log.info("第二个线程 {} 获取锁 =====> 重量级锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁->偏向锁(特例)
这是一个很奇怪的测试用例,它是在测试中唯一发生不同线程对同一个锁获得偏向锁的情况。但是排查过程中发现两个线程的
tid
相同,猜测是局部变量表槽位复用时有什么优化机制。--卡了我好久,也没有探究到实质的新信息。
+可以通过
vim ~/.vimrc
修改配置文件。- -
public static void main(String[] args) throws IOException, InterruptedException {
log.info("测试:之前获得偏向锁的线程已死时,新线程获得的仍然是偏向锁");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (lock) {
log.info("第一个线程 {} 获取锁 =====> 偏向锁", Thread.currentThread().getName());
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread1");
thread1.start();
thread1.join();
Thread thread2 = new Thread(() -> {
synchronized (lock) {
log.info("第二个线程 {} 获取锁,=====> 偏向锁", Thread.currentThread().getName());
log.info("震惊!!!为什么两个 tid 相同啊,有什么复用机制吗");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}, "thread2");
thread2.start();
thread2.join();
log.info("偏向锁等到竞争出现才释放锁,因此离开同步方法块后,Mark Word 不变");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}匿名偏向锁状态计算 hashCode
在匿名偏向锁状态计算
-hashCode
,锁将变为无锁状态。- -
public static void main(String[] args) throws InterruptedException {
log.info("测试:在匿名偏向锁状态计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁状态无锁时计算 hashCode
在偏向锁状态无锁时计算
-hashCode
,锁将变为无锁状态。- -
public static void main(String[] args) throws InterruptedException {
log.info("测试:在偏向锁状态无锁时计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
int hashCode = lock.hashCode();
log.info("离开同步块后再计算 hashCode:Mark Word =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}偏向锁状态加锁时计算 hashCode
在偏向锁状态加锁时计算
-hashCode
,锁将升级为重量级锁状态。- -
public static void main(String[] args) throws InterruptedException {
log.info("测试:在偏向锁状态计算 hashCode");
log.info("sleep 4000ms,等待偏向锁激活");
TimeUnit.MILLISECONDS.sleep(4000);
Object lock = new Object();
log.info("Mark Word 初始为 =====> 匿名偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 偏向锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}轻量级锁
轻量级锁加锁
获取偏向锁失败后续处理一(是否是偏向锁为
-0
):-
- 检测锁标志位是否为
+01
或者00
-
-- 如果否,则说明是重量级锁状态 =====> 获取轻量级锁失败后续处理一
-- 如果是,则说明是无锁状态或者轻量级锁状态,尝试通过
-CAS
操作设置轻量级锁-
-- 如果成功,说明获得轻量级锁
-- 如果失败,说明发生竞争 =====> 获取轻量级锁失败后续处理二
-参考文章
--在通过
+]]> +CAS
操作设置轻量级锁中,Compare
操作是“测试Mark Word
的锁标志位是否为01
,代表处于无锁状态”,Swap
操作是将Mark Word
复制到栈中锁记录,并将指向栈中锁记录的指针设置到Mark Word
的相应位置以及修改锁标志位。所谓“栈中锁记录”又称为Displaced Mark Word
,JVM
会在当前线程的栈帧中创建用于存储锁记录的空间,用于在轻量级锁状态下临时存放Mark Word
。+ + +linux +vim ++ diff --git a/tags/index.html b/tags/index.html index 6afaa598..5ee43efb 100644 --- a/tags/index.html +++ b/tags/index.html @@ -27,7 +27,7 @@ - +k3s 的安装和使用 +/2024/01/30/installation-and-use-of-k3s/ +本文记录了 k3s
的安装和使用,相较于minikube
,前者是一个完全兼容的Kubernetes
发行版,安装和使用的体验更佳。 + + +安装
++参考官方文档-快速入门指南,使用默认选项启动集群非常简单方便!!!
步骤
+
- 获取并运行
+k3s
安装脚本。官方为中国用户提供了镜像加速支持。
$ curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | INSTALL_K3S_MIRROR=cn sh -
[sudo] password for moralok:
[INFO] Finding release for channel stable
[INFO] Using v1.28.5+k3s1 as release
[INFO] Downloading hash rancher-mirror.rancher.cn/k3s/v1.28.5-k3s1/sha256sum-amd64.txt
[INFO] Downloading binary rancher-mirror.rancher.cn/k3s/v1.28.5-k3s1/k3s
[INFO] Verifying binary download
[INFO] Installing k3s to /usr/local/bin/k3s
[INFO] Skipping installation of SELinux RPM
[INFO] Creating /usr/local/bin/kubectl symlink to k3s
[INFO] Creating /usr/local/bin/crictl symlink to k3s
[INFO] Skipping /usr/local/bin/ctr symlink to k3s, command exists in PATH at /usr/bin/ctr
[INFO] Creating killall script /usr/local/bin/k3s-killall.sh
[INFO] Creating uninstall script /usr/local/bin/k3s-uninstall.sh
[INFO] env: Creating environment file /etc/systemd/system/k3s.service.env
[INFO] systemd: Creating service file /etc/systemd/system/k3s.service
sh: 1014: restorecon: not found
sh: 1015: restorecon: not found
[INFO] systemd: Enabling k3s unit
Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service → /etc/systemd/system/k3s.service.
[INFO] systemd: Starting k3s- 可以通过使用
+kubectl
确认安装成功。刚安装的时候使用kubectl
需要root
权限。
$ sudo kubectl get node
NAME STATUS ROLES AGE VERSION
ubuntu-server Ready control-plane,master 52m v1.28.5+k3s1- 实际上安装的就是一个
+k3s
可执行文件,kubectl
和crictl
只是软链接,指向k3s
。
$ ls /usr/local/bin/
crictl k3s k3s-killall.sh k3s-uninstall.sh kubectl--在轻量级锁状态下,明确提及了锁记录的作用,但偏向锁状态下,提及锁记录却并未加以解释。
+安装的信息中显示了
k3s
的service file
和environment file
的路径,后续修改启动参数和环境变量需要用到。获取偏向锁失败后续处理一:
--
-- 已经升级为重量级锁
-获取偏向锁失败后续处理二(通过
-CAS
加偏向锁失败):-
-- 获取锁失败的线程将锁升级为重量级锁,修改
-Mark Word
为指向互斥量(重量级锁)的指针|10
(这个操作将影响到持有轻量级锁的线程的解锁)- 线程阻塞,等待唤醒
-补充思考:
--
-- 有相关资料提及偏向锁并非直接升级到重量级锁,无法验证是否总是有轻量级锁作为中间状态
-- 轻量级锁面临竞争时升级为重量级锁的过程相比于偏向锁面临竞争时的升级过程,更加容易理解,后者好多细节没有找到令人信服的答案。
-轻量级锁解锁
轻量级锁解锁时,会通过
-CAS
操作解锁,Compare
操作是“测试Mark Word
的锁标志位是否为00
,代表处于轻量级锁状态,Swap
操作是将栈中锁记录Dispaced Mark Word
替换回对象头的Mark Word
以及修改锁标志位。
如果Compare
操作失败,则代表发生竞争,此时锁已经被其他线程升级为重量级锁以及Mark Word
被修改为指向互斥量(重量级锁)的指针|10
。持有轻量级锁的线程会释放锁(直接将Dispaced Mark Word
替换回Mark Word
?)并唤醒等待的线程,开启新的一轮争抢。测试轻量级锁升级
无锁->轻量级锁
测试在无锁状态下获取锁,锁将变成轻量级锁状态。
-+
public static void main(String[] args) throws IOException, InterruptedException {
Scanner scanner = new Scanner(System.in);
log.info("轻量级锁基础测试:无锁状态 -> 轻量级锁");
Object lock = new Object();
log.info("在偏向锁激活之前创建的对象为 =====> 无锁状态(可偏向额)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("即使是单线程无竞争获取锁,=====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
log.info("暂停,回车继续");
scanner.nextLine();
}
log.info("离开同步块后,-> 无锁状态(可偏向的)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}配置文件权限问题
在刚安装完
+k3s
的时候,使用kubectl
需要root
权限。根据报错信息可知,是因为非root
用户无法读取配置文件/etc/rancher/k3s/k3s.yaml
。-
$ kubectl get node
WARN[0000] Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode to modify kube config permissions
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied无锁状态计算 hashCode
在无锁状态计算
-hashCode
,仍然是无锁状态。+
public static void main(String[] args) throws InterruptedException {
log.info("测试:在无锁状态计算 hashCode");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("离开同步块后轻量级锁释放 =====> 无锁状态(hash|age|0|01)");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}查看配置文件的信息可知其权限配置为
+600
,只有root
用户具有读写权限。-
$ ll /etc/rancher/k3s/k3s.yaml
-rw------- 1 root root 2961 Jan 30 18:58 /etc/rancher/k3s/k3s.yaml轻量级锁加锁时计算 hashCode
在轻量级锁状态加锁时计算
-hashCode
,锁将升级为重量级锁状态。+
public static void main(String[] args) throws InterruptedException {
log.info("测试:在轻量级锁状态计算 hashCode");
Object lock = new Object();
log.info("Mark Word 初始为 =====> 无锁状态");
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("获取锁 =====> 轻量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
int hashCode = lock.hashCode();
log.info("在计算 hashCode 后:Mark Word =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
log.info("即使离开同步块后 =====> 重量级锁");
log.info(ClassLayout.parseInstance(lock).toPrintable());
}一般来说,我们希望能够通过非
+root
用户使用kubectl
,避免通过root
用户或者通过sudo
加输入密码的形式来使用kubectl
。那么如何解决这个问题呢?本质上这是一个Linux
的文件权限问题,似乎修改文件的权限配置就可以解决。但是提示信息给出的解决方案并不是那么直接,它告诉我们通过修改k3s server
的启动参数来达到修改配置文件权限的目的。这是因为k3s
服务在每次重启时会根据启动参数和环境变量重置配置文件/etc/rancher/k3s/k3s.yaml
,手动修改文件的权限配置并不能优雅地解决这个问题,一旦服务重启,修改就会丢失。++k3s 的 Github Discussions 中讨论了这个问题,并链接了文档 管理 Kubeconfig 选项,文档介绍了通过修改启动参数和环境变量达到修改配置文件权限的目的。
+修改启动参数
第一种方式是修改启动参数。
++
+- +
sudo vim /etc/systemd/system/k3s.service
添加k3s
启动参数--write-kubeconfig-mode 644
ExecStart=/usr/local/bin/k3s \
server --write-kubeconfig-mode 644 \- +
systemctl daemon-reload
重新加载systemd
配置- +
systemctl restart k3s.service
重启服务- 验证修改生效
+
$ ll /etc/rancher/k3s/k3s.yaml
-rw-r--r-- 1 root root 2961 Jan 30 20:13 /etc/rancher/k3s/k3s.yaml修改环境变量
第二种方式是修改环境变量。
++
+- +
sudo vim /etc/systemd/system/k3s.service.env
添加环境变量K3S_KUBECONFIG_MODE=644
K3S_KUBECONFIG_MODE=644- +
systemctl restart k3s.service
重启服务修改配置文件路径
第三种方式是复制配置信息到当前用户目录下,并使用其作为配置文件的路径。
++
+- 设置环境变量
+export KUBECONFIG=~/.kube/config
- 创建文件夹
+mkdir ~/.kube 2> /dev/null
- 复制配置信息
+sudo k3s kubectl config view --raw > "$KUBECONFIG"
- 修改配置文件的权限
+chmod 600 "$KUBECONFIG"
配置代理
涉及
+k8s
,难免需要使用代理,否则在拉取镜像时将寸步难行。官方文档 配置 HTTP 代理 介绍了如何配置代理。其中提及k3s
安装脚本会自动使用当前shell
中的HTTP_PROXY
、HTTPS_PROXY
和NO_PROXY
,以及CONTAINERD_HTTP_PROXY
、CONTAINERD_HTTPS_PROXY
和CONTAINERD_NO_PROXY
变量(如果存在),并将它们写入systemd
服务的环境文件。比如我设置过shell
变量HTTP_PROXY
、HTTPS_PROXY
和NO_PROXY
,/etc/systemd/system/k3s.service.env
如下,你也可以自行编辑修改。+
http_proxy='http://127.0.0.1:7890'
https_proxy='http://127.0.0.1:7890'
no_proxy='localhost,127.0.0.1'
K3S_KUBECONFIG_MODE=644使用
++k8s 基础教程可参考官方文档 Kubernetes 基础。
+创建 Deployment
+
+- 使用
+kubectl create
命令创建管理Pod
的Deployment
。该Pod
根据提供的Docker
镜像运行容器。
kubectl create deployment hello-node --image=registry.k8s.io/e2e-test-images/agnhost:2.39 -- /agnhost netexec --http-port=8080- 查看
+Deployment
:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
hello-node-ccf4b9788-d8k9b 1/1 Running 0 15h- 查看
+Pod
中容器的应用程序日志。
$ kubectl logs hello-node-ccf4b9788-d8k9b
I0130 19:26:57.751131 1 log.go:195] Started HTTP server on port 8080
I0130 19:26:57.751350 1 log.go:195] Started UDP server on port 8081创建 Service
+
+- 使用
+kubectl expose
命令将Pod
暴露给公网:
kubectl expose deployment hello-node --type=LoadBalancer --port=8080- 查看你创建的
+Service
:
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.43.0.1 <none> 443/TCP 15h
hello-node LoadBalancer 10.43.37.170 192.168.46.128 8080:32117/TCP 15h- 使用
+curl
发起请求:
$ curl http://localhost:8080
NOW: 2024-01-31 10:55:14.228709273 +0000 UTC m=+25932.159732511- 再次查看
+Pod
中容器的应用程序日志。
$ kubectl logs hello-node-ccf4b9788-d8k9b
I0130 19:26:57.751131 1 log.go:195] Started HTTP server on port 8080
I0130 19:26:57.751350 1 log.go:195] Started UDP server on port 8081
I0130 19:32:21.074992 1 log.go:195] GET /清理
+
- 删除
+Service
:
kubectl delete service hello-node- 删除
+Deployment
:
kubectl delete deployment hello-node参考文章
-
]]>- 《Java 并发编程的艺术》
-- 难搞的偏向锁终于被 Java 移除了
-- Details about mark word of java object header
+- 快速入门指南
+- 管理 Kubeconfig 选项
+- 配置 HTTP 代理
+- 你好,Minikube
+- Permission denied on non-existing /etc/rancher/k3s/config.yaml after fresh install
+- error: error loading config file “/etc/rancher/k3s/k3s.yaml”: open /etc/rancher/k3s/k3s.yaml: permission denied
+- /etc/rancher/k3s/k3s.yaml is world readable #389
- java -lock -synchronized +k8s +k3s